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/__init__.py +13 -0
- uiprotect/__main__.py +24 -0
- uiprotect/api.py +1936 -0
- uiprotect/cli/__init__.py +314 -0
- uiprotect/cli/backup.py +1103 -0
- uiprotect/cli/base.py +238 -0
- uiprotect/cli/cameras.py +574 -0
- uiprotect/cli/chimes.py +180 -0
- uiprotect/cli/doorlocks.py +125 -0
- uiprotect/cli/events.py +258 -0
- uiprotect/cli/lights.py +119 -0
- uiprotect/cli/liveviews.py +65 -0
- uiprotect/cli/nvr.py +154 -0
- uiprotect/cli/sensors.py +278 -0
- uiprotect/cli/viewers.py +76 -0
- uiprotect/data/__init__.py +157 -0
- uiprotect/data/base.py +1116 -0
- uiprotect/data/bootstrap.py +634 -0
- uiprotect/data/convert.py +77 -0
- uiprotect/data/devices.py +3384 -0
- uiprotect/data/nvr.py +1520 -0
- uiprotect/data/types.py +630 -0
- uiprotect/data/user.py +236 -0
- uiprotect/data/websocket.py +236 -0
- uiprotect/exceptions.py +41 -0
- uiprotect/py.typed +0 -0
- uiprotect/release_cache.json +1 -0
- uiprotect/stream.py +166 -0
- uiprotect/test_util/__init__.py +531 -0
- uiprotect/test_util/anonymize.py +257 -0
- uiprotect/utils.py +610 -0
- uiprotect/websocket.py +225 -0
- uiprotect-0.1.0.dist-info/LICENSE +23 -0
- uiprotect-0.1.0.dist-info/METADATA +245 -0
- uiprotect-0.1.0.dist-info/RECORD +37 -0
- uiprotect-0.1.0.dist-info/WHEEL +4 -0
- uiprotect-0.1.0.dist-info/entry_points.txt +3 -0
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
|