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/utils.py ADDED
@@ -0,0 +1,610 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import json
6
+ import logging
7
+ import math
8
+ import os
9
+ import re
10
+ import socket
11
+ import sys
12
+ import time
13
+ from collections import Counter
14
+ from collections.abc import Callable, Coroutine, Iterable
15
+ from copy import deepcopy
16
+ from datetime import datetime, timedelta, timezone, tzinfo
17
+ from decimal import Decimal
18
+ from enum import Enum
19
+ from functools import lru_cache
20
+ from hashlib import sha224
21
+ from http.cookies import Morsel
22
+ from inspect import isclass
23
+ from ipaddress import IPv4Address, IPv6Address, ip_address
24
+ from pathlib import Path
25
+ from typing import TYPE_CHECKING, Any, TypeVar, Union, overload
26
+ from uuid import UUID
27
+
28
+ import jwt
29
+ import zoneinfo
30
+ from aiohttp import ClientResponse
31
+ from pydantic.v1.fields import SHAPE_DICT, SHAPE_LIST, SHAPE_SET, ModelField
32
+ from pydantic.v1.utils import to_camel
33
+
34
+ from uiprotect.data.types import (
35
+ Color,
36
+ SmartDetectAudioType,
37
+ SmartDetectObjectType,
38
+ Version,
39
+ VideoMode,
40
+ )
41
+ from uiprotect.exceptions import NvrError
42
+
43
+ if TYPE_CHECKING:
44
+ from uiprotect.api import ProtectApiClient
45
+ from uiprotect.data import CoordType, Event
46
+ from uiprotect.data.bootstrap import WSStat
47
+
48
+ if sys.version_info[:2] < (3, 11):
49
+ from async_timeout import timeout as asyncio_timeout
50
+ else:
51
+ from asyncio import timeout as asyncio_timeout # noqa: F401
52
+
53
+ T = TypeVar("T")
54
+
55
+ DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
56
+ DEBUG_ENV = "UFP_DEBUG"
57
+ PROGRESS_CALLABLE = Callable[[int, str], Coroutine[Any, Any, None]]
58
+ SNAKE_CASE_KEYS = [
59
+ "life_span",
60
+ "bad_sector",
61
+ "total_bytes",
62
+ "used_bytes",
63
+ "space_type",
64
+ ]
65
+ TIMEZONE_GLOBAL: tzinfo | None = None
66
+
67
+ SNAKE_CASE_MATCH_1 = re.compile("(.)([A-Z0-9][a-z]+)")
68
+ SNAKE_CASE_MATCH_2 = re.compile("__([A-Z0-9])")
69
+ SNAKE_CASE_MATCH_3 = re.compile("([a-z0-9])([A-Z])")
70
+
71
+ _LOGGER = logging.getLogger(__name__)
72
+
73
+ RELEASE_CACHE = Path(__file__).parent / "release_cache.json"
74
+
75
+ _CREATE_TYPES = {IPv6Address, IPv4Address, UUID, Color, Decimal, Path, Version}
76
+ _BAD_UUID = "00000000-0000-00 0- 000-000000000000"
77
+
78
+ IP_TYPES = {
79
+ Union[IPv4Address, str, None],
80
+ Union[IPv4Address, str],
81
+ Union[IPv6Address, str, None],
82
+ Union[IPv6Address, str],
83
+ Union[IPv6Address, IPv4Address, str, None],
84
+ Union[IPv6Address, IPv4Address, str],
85
+ Union[IPv6Address, IPv4Address],
86
+ Union[IPv6Address, IPv4Address, None],
87
+ }
88
+
89
+ if sys.version_info[:2] < (3, 11):
90
+ pass
91
+ else:
92
+ pass
93
+
94
+
95
+ def set_debug() -> None:
96
+ """Sets ENV variable for UFP_DEBUG to on (True)"""
97
+ os.environ[DEBUG_ENV] = str(True)
98
+
99
+
100
+ def set_no_debug() -> None:
101
+ """Sets ENV variable for UFP_DEBUG to off (False)"""
102
+ os.environ[DEBUG_ENV] = str(False)
103
+
104
+
105
+ def is_debug() -> bool:
106
+ """Returns if debug ENV is on (True)"""
107
+ return os.environ.get(DEBUG_ENV) == str(True)
108
+
109
+
110
+ async def get_response_reason(response: ClientResponse) -> str:
111
+ reason = str(response.reason)
112
+
113
+ try:
114
+ data = await response.json()
115
+ reason = data.get("error", str(data))
116
+ except Exception:
117
+ with contextlib.suppress(Exception):
118
+ reason = await response.text()
119
+
120
+ return reason
121
+
122
+
123
+ @overload
124
+ def to_js_time(dt: datetime | int) -> int: ...
125
+
126
+
127
+ @overload
128
+ def to_js_time(dt: None) -> None: ...
129
+
130
+
131
+ def to_js_time(dt: datetime | int | None) -> int | None:
132
+ """Converts Python datetime to Javascript timestamp"""
133
+ if dt is None:
134
+ return None
135
+
136
+ if isinstance(dt, int):
137
+ return dt
138
+
139
+ if dt.tzinfo is None:
140
+ return int(time.mktime(dt.timetuple()) * 1000)
141
+
142
+ return int(dt.astimezone(timezone.utc).timestamp() * 1000)
143
+
144
+
145
+ def to_ms(duration: timedelta | None) -> int | None:
146
+ """Converts python timedelta to Milliseconds"""
147
+ if duration is None:
148
+ return None
149
+
150
+ return int(round(duration.total_seconds() * 1000))
151
+
152
+
153
+ def utc_now() -> datetime:
154
+ return datetime.now(tz=timezone.utc)
155
+
156
+
157
+ def from_js_time(num: float | str | datetime) -> datetime:
158
+ """Converts Javascript timestamp to Python datetime"""
159
+ if isinstance(num, datetime):
160
+ return num
161
+
162
+ return datetime.fromtimestamp(int(num) / 1000, tz=timezone.utc)
163
+
164
+
165
+ def process_datetime(data: dict[str, Any], key: str) -> datetime | None:
166
+ """Extracts datetime object from Protect dictionary"""
167
+ return None if data.get(key) is None else from_js_time(data[key])
168
+
169
+
170
+ def format_datetime(
171
+ dt: datetime | None,
172
+ default: str | None = None,
173
+ ) -> str | None:
174
+ """Formats a datetime object in a consisent format"""
175
+ return default if dt is None else dt.strftime(DATETIME_FORMAT)
176
+
177
+
178
+ def is_online(data: dict[str, Any]) -> bool:
179
+ return bool(data["state"] == "CONNECTED")
180
+
181
+
182
+ def is_doorbell(data: dict[str, Any]) -> bool:
183
+ return "doorbell" in str(data["type"]).lower()
184
+
185
+
186
+ @lru_cache(maxsize=1024)
187
+ def to_snake_case(name: str) -> str:
188
+ """Converts string to snake_case"""
189
+ name = SNAKE_CASE_MATCH_1.sub(r"\1_\2", name)
190
+ name = SNAKE_CASE_MATCH_2.sub(r"_\1", name)
191
+ name = SNAKE_CASE_MATCH_3.sub(r"\1_\2", name)
192
+ return name.lower()
193
+
194
+
195
+ def to_camel_case(name: str) -> str:
196
+ """Converts string to camelCase"""
197
+ # repeated runs through should not keep lowercasing
198
+ if "_" in name:
199
+ name = to_camel(name)
200
+ return name[0].lower() + name[1:]
201
+ return name
202
+
203
+
204
+ def convert_unifi_data(value: Any, field: ModelField) -> Any:
205
+ """Converts value from UFP data into pydantic field class"""
206
+ type_ = field.type_
207
+
208
+ if type_ == Any:
209
+ return value
210
+
211
+ shape = field.shape
212
+ if shape == SHAPE_LIST and isinstance(value, list):
213
+ return [convert_unifi_data(v, field) for v in value]
214
+ if shape == SHAPE_SET and isinstance(value, list):
215
+ return {convert_unifi_data(v, field) for v in value}
216
+ if shape == SHAPE_DICT and isinstance(value, dict):
217
+ return {k: convert_unifi_data(v, field) for k, v in value.items()}
218
+
219
+ if value is not None:
220
+ if type_ in IP_TYPES:
221
+ try:
222
+ return ip_address(value)
223
+ except ValueError:
224
+ return value
225
+ if type_ == datetime:
226
+ return from_js_time(value)
227
+ if type_ in _CREATE_TYPES or (isclass(type_) and issubclass(type_, Enum)):
228
+ # cannot do this check too soon because some types cannot be used in isinstance
229
+ if isinstance(value, type_):
230
+ return value
231
+ # handle edge case for improperly formated UUIDs
232
+ # 00000000-0000-00 0- 000-000000000000
233
+ if type_ == UUID and value == _BAD_UUID:
234
+ value = "0" * 32
235
+ return type_(value)
236
+
237
+ return value
238
+
239
+
240
+ def serialize_unifi_obj(value: Any, levels: int = -1) -> Any:
241
+ """Serializes UFP data"""
242
+ if unifi_dict := getattr(value, "unifi_dict", None):
243
+ value = unifi_dict()
244
+
245
+ if levels != 0 and isinstance(value, dict):
246
+ return serialize_dict(value, levels=levels - 1)
247
+ if levels != 0 and isinstance(value, Iterable) and not isinstance(value, str):
248
+ return serialize_list(value, levels=levels - 1)
249
+ if isinstance(value, Enum):
250
+ return value.value
251
+ if isinstance(value, (IPv4Address, IPv6Address, UUID, Path, tzinfo, Version)):
252
+ return str(value)
253
+ if isinstance(value, datetime):
254
+ return to_js_time(value)
255
+ if isinstance(value, timedelta):
256
+ return to_ms(value)
257
+ if isinstance(value, Color):
258
+ return value.as_hex().upper()
259
+
260
+ return value
261
+
262
+
263
+ def serialize_dict(data: dict[str, Any], levels: int = -1) -> dict[str, Any]:
264
+ """Serializes UFP data dict"""
265
+ for key in list(data.keys()):
266
+ set_key = key
267
+ if set_key not in SNAKE_CASE_KEYS:
268
+ set_key = to_camel_case(set_key)
269
+ data[set_key] = serialize_unifi_obj(data.pop(key), levels=levels)
270
+
271
+ return data
272
+
273
+
274
+ def serialize_coord(coord: CoordType) -> int | float:
275
+ """Serializes UFP zone coordinate"""
276
+ from uiprotect.data import Percent
277
+
278
+ if not isinstance(coord, Percent):
279
+ return coord
280
+
281
+ if math.isclose(coord, 0) or math.isclose(coord, 1):
282
+ return int(coord)
283
+ return coord
284
+
285
+
286
+ def serialize_point(point: tuple[CoordType, CoordType]) -> list[int | float]:
287
+ """Serializes UFP zone coordinate point"""
288
+ return [
289
+ serialize_coord(point[0]),
290
+ serialize_coord(point[1]),
291
+ ]
292
+
293
+
294
+ def serialize_list(items: Iterable[Any], levels: int = -1) -> list[Any]:
295
+ """Serializes UFP data list"""
296
+ return [serialize_unifi_obj(i, levels=levels) for i in items]
297
+
298
+
299
+ def convert_smart_types(items: Iterable[str]) -> list[SmartDetectObjectType]:
300
+ """Converts list of str into SmartDetectObjectType. Any unknown values will be ignored and logged."""
301
+ types = []
302
+ for smart_type in items:
303
+ try:
304
+ types.append(SmartDetectObjectType(smart_type))
305
+ except ValueError:
306
+ _LOGGER.warning("Unknown smart detect type: %s", smart_type)
307
+ return types
308
+
309
+
310
+ def convert_smart_audio_types(items: Iterable[str]) -> list[SmartDetectAudioType]:
311
+ """Converts list of str into SmartDetectAudioType. Any unknown values will be ignored and logged."""
312
+ types = []
313
+ for smart_type in items:
314
+ try:
315
+ types.append(SmartDetectAudioType(smart_type))
316
+ except ValueError:
317
+ _LOGGER.warning("Unknown smart detect audio type: %s", smart_type)
318
+ return types
319
+
320
+
321
+ def convert_video_modes(items: Iterable[str]) -> list[VideoMode]:
322
+ """Converts list of str into VideoMode. Any unknown values will be ignored and logged."""
323
+ types = []
324
+ for video_mode in items:
325
+ try:
326
+ types.append(VideoMode(video_mode))
327
+ except ValueError:
328
+ _LOGGER.warning("Unknown video mode: %s", video_mode)
329
+ return types
330
+
331
+
332
+ def ip_from_host(host: str) -> IPv4Address | IPv6Address:
333
+ try:
334
+ return ip_address(host)
335
+ except ValueError:
336
+ pass
337
+
338
+ return ip_address(socket.gethostbyname(host))
339
+
340
+
341
+ def dict_diff(orig: dict[str, Any] | None, new: dict[str, Any]) -> dict[str, Any]:
342
+ changed: dict[str, Any] = {}
343
+
344
+ if orig is None:
345
+ return new
346
+
347
+ for key, value in new.items():
348
+ if key not in orig:
349
+ changed[key] = deepcopy(value)
350
+ continue
351
+
352
+ if isinstance(value, dict):
353
+ sub_changed = dict_diff(orig[key], value)
354
+
355
+ if sub_changed:
356
+ changed[key] = sub_changed
357
+ elif value != orig[key]:
358
+ changed[key] = deepcopy(value)
359
+
360
+ return changed
361
+
362
+
363
+ def ws_stat_summmary(
364
+ stats: list[WSStat],
365
+ ) -> tuple[list[WSStat], float, Counter[str], Counter[str], Counter[str]]:
366
+ if len(stats) == 0:
367
+ raise ValueError("No stats to summarize")
368
+
369
+ unfiltered = [s for s in stats if not s.filtered]
370
+ percent = (1 - len(unfiltered) / len(stats)) * 100
371
+ keys = Counter(k for s in unfiltered for k in s.keys_set)
372
+ models = Counter(k.model for k in unfiltered)
373
+ actions = Counter(k.action for k in unfiltered)
374
+
375
+ return unfiltered, percent, keys, models, actions
376
+
377
+
378
+ async def write_json(output_path: Path, data: list[Any] | dict[str, Any]) -> None:
379
+ def write() -> None:
380
+ with open(output_path, "w", encoding="utf-8") as f:
381
+ json.dump(data, f, indent=4)
382
+ f.write("\n")
383
+
384
+ loop = asyncio.get_running_loop()
385
+ await loop.run_in_executor(None, write)
386
+
387
+
388
+ def print_ws_stat_summary(
389
+ stats: list[WSStat],
390
+ output: Callable[[Any], Any] | None = None,
391
+ ) -> None:
392
+ # typer<0.4.1 is incompatible with click>=8.1.0
393
+ # allows only the CLI interface to break if both are installed
394
+ import typer
395
+
396
+ if output is None:
397
+ output = typer.echo if typer is not None else print
398
+
399
+ unfiltered, percent, keys, models, actions = ws_stat_summmary(stats)
400
+
401
+ title = " ws stat summary "
402
+ side_length = int((80 - len(title)) / 2)
403
+
404
+ lines = [
405
+ "-" * side_length + title + "-" * side_length,
406
+ f"packet count: {len(stats)}",
407
+ f"filtered packet count: {len(unfiltered)} ({percent:.4}%)",
408
+ "-" * 80,
409
+ ]
410
+
411
+ for key, count in models.most_common():
412
+ lines.append(f"{key}: {count}")
413
+ lines.append("-" * 80)
414
+
415
+ for key, count in actions.most_common():
416
+ lines.append(f"{key}: {count}")
417
+ lines.append("-" * 80)
418
+
419
+ for key, count in keys.most_common(10):
420
+ lines.append(f"{key}: {count}")
421
+ lines.append("-" * 80)
422
+
423
+ output("\n".join(lines))
424
+
425
+
426
+ async def profile_ws(
427
+ protect: ProtectApiClient,
428
+ duration: int,
429
+ output_path: Path | None = None,
430
+ ws_progress: PROGRESS_CALLABLE | None = None,
431
+ do_print: bool = True,
432
+ print_output: Callable[[Any], Any] | None = None,
433
+ ) -> None:
434
+ if protect.bootstrap.capture_ws_stats:
435
+ raise NvrError("Profile already in progress")
436
+
437
+ _LOGGER.debug("Starting profile...")
438
+ protect.bootstrap.clear_ws_stats()
439
+ protect.bootstrap.capture_ws_stats = True
440
+
441
+ if ws_progress is not None:
442
+ await ws_progress(duration, "Waiting for WS messages")
443
+ else:
444
+ await asyncio.sleep(duration)
445
+
446
+ protect.bootstrap.capture_ws_stats = False
447
+ _LOGGER.debug("Finished profile...")
448
+
449
+ if output_path:
450
+ json_data = [s.__dict__ for s in protect.bootstrap.ws_stats]
451
+ await write_json(output_path, json_data)
452
+
453
+ if do_print:
454
+ print_ws_stat_summary(protect.bootstrap.ws_stats, output=print_output)
455
+
456
+
457
+ def decode_token_cookie(token_cookie: Morsel[str]) -> dict[str, Any] | None:
458
+ """Decode a token cookie if it is still valid."""
459
+ try:
460
+ return jwt.decode(
461
+ token_cookie.value,
462
+ options={"verify_signature": False, "verify_exp": True},
463
+ )
464
+ except jwt.ExpiredSignatureError:
465
+ _LOGGER.debug("Authentication token has expired.")
466
+ return None
467
+ except Exception as broad_ex:
468
+ _LOGGER.debug("Authentication token decode error: %s", broad_ex)
469
+ return None
470
+
471
+
472
+ def format_duration(duration: timedelta) -> str:
473
+ """Formats a timedelta as a string."""
474
+ seconds = int(duration.total_seconds())
475
+ hours = seconds // 3600
476
+ seconds -= hours * 3600
477
+ minutes = seconds // 60
478
+ seconds -= minutes * 60
479
+
480
+ output = ""
481
+ if hours > 0:
482
+ output = f"{hours}h"
483
+ if minutes > 0:
484
+ output = f"{output}{minutes}m"
485
+ return f"{output}{seconds}s"
486
+
487
+
488
+ def _set_timezone(tz: tzinfo | str) -> tzinfo:
489
+ global TIMEZONE_GLOBAL
490
+
491
+ if isinstance(tz, str):
492
+ tz = zoneinfo.ZoneInfo(tz)
493
+
494
+ TIMEZONE_GLOBAL = tz
495
+
496
+ return TIMEZONE_GLOBAL
497
+
498
+
499
+ def get_local_timezone() -> tzinfo:
500
+ """Gets Olson timezone name for localizing datetimes"""
501
+ if TIMEZONE_GLOBAL is not None:
502
+ return TIMEZONE_GLOBAL
503
+
504
+ try:
505
+ from homeassistant.util import dt as dt_util # type: ignore[import-not-found]
506
+
507
+ return _set_timezone(dt_util.DEFAULT_TIME_ZONE)
508
+ except ImportError:
509
+ pass
510
+
511
+ timezone_name = os.environ.get("TZ")
512
+ if timezone_name:
513
+ return _set_timezone(timezone_name)
514
+
515
+ timezone_name = "UTC"
516
+ timezone_locale = Path("/etc/localtime")
517
+ if timezone_locale.exists():
518
+ tzfile_digest = sha224(Path(timezone_locale).read_bytes()).hexdigest()
519
+
520
+ for root, _, filenames in os.walk(Path("/usr/share/zoneinfo/")):
521
+ for filename in filenames:
522
+ fullname = os.path.join(root, filename)
523
+ digest = sha224(Path(fullname).read_bytes()).hexdigest()
524
+ if digest == tzfile_digest:
525
+ timezone_name = "/".join((fullname.split("/"))[-2:])
526
+
527
+ return _set_timezone(timezone_name)
528
+
529
+
530
+ def local_datetime(dt: datetime | None = None) -> datetime:
531
+ """Returns datetime in local timezone"""
532
+ if dt is None:
533
+ dt = datetime.now(tz=timezone.utc)
534
+
535
+ local_tz = get_local_timezone()
536
+ if dt.tzinfo is None:
537
+ return dt.replace(tzinfo=local_tz)
538
+ return dt.astimezone(local_tz)
539
+
540
+
541
+ def log_event(event: Event) -> None:
542
+ from uiprotect.data import EventType
543
+
544
+ _LOGGER.debug("event WS msg: %s", event.dict())
545
+ if "smart" not in event.type.value:
546
+ return
547
+
548
+ camera = event.camera
549
+ if camera is None:
550
+ return
551
+
552
+ if event.end is not None:
553
+ _LOGGER.debug(
554
+ "%s (%s): Smart detection ended for %s (%s)",
555
+ camera.name,
556
+ camera.mac,
557
+ event.smart_detect_types,
558
+ event.id,
559
+ )
560
+ return
561
+
562
+ _LOGGER.debug(
563
+ "%s (%s): New smart detection started for %s (%s)",
564
+ camera.name,
565
+ camera.mac,
566
+ event.smart_detect_types,
567
+ event.id,
568
+ )
569
+ smart_settings = camera.smart_detect_settings
570
+ for smart_type in event.smart_detect_types:
571
+ is_audio = event.type == EventType.SMART_AUDIO_DETECT
572
+ if is_audio:
573
+ if smart_type.audio_type is None:
574
+ return
575
+
576
+ is_enabled = (
577
+ smart_settings.audio_types is not None
578
+ and smart_type.audio_type in smart_settings.audio_types
579
+ )
580
+ last_event = camera.get_last_smart_audio_detect_event(smart_type.audio_type)
581
+ else:
582
+ is_enabled = smart_type in smart_settings.object_types
583
+ last_event = camera.get_last_smart_detect_event(smart_type)
584
+
585
+ _LOGGER.debug(
586
+ "Event info (%s):\n"
587
+ " is_smart_detected: %s\n"
588
+ " is_recording_enabled: %s\n"
589
+ " is_enabled: %s\n"
590
+ " event: %s",
591
+ smart_type,
592
+ camera.is_smart_detected,
593
+ camera.is_recording_enabled,
594
+ is_enabled,
595
+ last_event,
596
+ )
597
+
598
+
599
+ def run_async(callback: Coroutine[Any, Any, T]) -> T:
600
+ """Run async coroutine."""
601
+ if sys.version_info >= (3, 11):
602
+ return asyncio.run(callback)
603
+ loop = asyncio.get_event_loop() # type: ignore[unreachable]
604
+ return loop.run_until_complete(callback)
605
+
606
+
607
+ def clamp_value(value: float, step_size: float) -> float:
608
+ """Clamps value to multiples of step size."""
609
+ ratio = 1 / step_size
610
+ return int(value * ratio) / ratio