pyezvizapi 1.0.2.2__tar.gz → 1.0.2.3__tar.gz
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 pyezvizapi might be problematic. Click here for more details.
- {pyezvizapi-1.0.2.2/pyezvizapi.egg-info → pyezvizapi-1.0.2.3}/PKG-INFO +1 -1
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/camera.py +10 -139
- pyezvizapi-1.0.2.3/pyezvizapi/utils.py +346 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3/pyezvizapi.egg-info}/PKG-INFO +1 -1
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/setup.py +1 -1
- pyezvizapi-1.0.2.2/pyezvizapi/utils.py +0 -190
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/LICENSE +0 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/LICENSE.md +0 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/MANIFEST.in +0 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/README.md +0 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/__init__.py +0 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/__main__.py +0 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/api_endpoints.py +0 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/cas.py +0 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/client.py +0 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/constants.py +0 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/exceptions.py +0 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/light_bulb.py +0 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/models.py +0 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/mqtt.py +0 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/test_cam_rtsp.py +0 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/test_mqtt.py +0 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi.egg-info/SOURCES.txt +0 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi.egg-info/dependency_links.txt +0 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi.egg-info/entry_points.txt +0 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi.egg-info/requires.txt +0 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi.egg-info/top_level.txt +0 -0
- {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/setup.cfg +0 -0
|
@@ -4,14 +4,17 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import datetime
|
|
6
6
|
import logging
|
|
7
|
-
import re
|
|
8
7
|
from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast
|
|
9
|
-
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
10
8
|
|
|
11
9
|
from .constants import BatteryCameraWorkMode, DeviceSwitchType, SoundMode
|
|
12
10
|
from .exceptions import PyEzvizError
|
|
13
11
|
from .models import EzvizDeviceRecord
|
|
14
|
-
from .utils import
|
|
12
|
+
from .utils import (
|
|
13
|
+
compute_motion_from_alarm,
|
|
14
|
+
fetch_nested_value,
|
|
15
|
+
parse_timezone_value,
|
|
16
|
+
string_to_list,
|
|
17
|
+
)
|
|
15
18
|
|
|
16
19
|
if TYPE_CHECKING:
|
|
17
20
|
from .client import EzvizClient
|
|
@@ -172,110 +175,10 @@ class EzvizCamera:
|
|
|
172
175
|
|
|
173
176
|
Prefer numeric epoch fields if available to avoid parsing localized strings.
|
|
174
177
|
"""
|
|
175
|
-
# Use timezone-aware datetimes. Compute both camera-local and UTC "now".
|
|
176
178
|
tzinfo = self._get_tzinfo()
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
# Prefer epoch fields if available
|
|
181
|
-
epoch = self._last_alarm.get("alarmStartTime") or self._last_alarm.get(
|
|
182
|
-
"alarmTime"
|
|
183
|
-
)
|
|
184
|
-
last_alarm_dt: datetime.datetime | None = None
|
|
185
|
-
# Capture string time if present so we can cross-check epoch skew
|
|
186
|
-
raw_time_str = str(
|
|
187
|
-
self._last_alarm.get("alarmStartTimeStr")
|
|
188
|
-
or self._last_alarm.get("alarmTimeStr")
|
|
189
|
-
or ""
|
|
179
|
+
active, seconds_out, last_alarm_str = compute_motion_from_alarm(
|
|
180
|
+
self._last_alarm, tzinfo
|
|
190
181
|
)
|
|
191
|
-
if epoch is not None:
|
|
192
|
-
try:
|
|
193
|
-
# Accept int/float/str; auto-detect ms vs s
|
|
194
|
-
if isinstance(epoch, str):
|
|
195
|
-
epoch = float(epoch)
|
|
196
|
-
ts = float(epoch)
|
|
197
|
-
if ts > 1e11: # very likely milliseconds
|
|
198
|
-
ts = ts / 1000.0
|
|
199
|
-
# Convert epoch to UTC for robust delta; derive display time in camera tz
|
|
200
|
-
event_utc = datetime.datetime.fromtimestamp(ts, tz=datetime.UTC)
|
|
201
|
-
last_alarm_dt = event_utc.astimezone(tzinfo)
|
|
202
|
-
# Some devices appear to report epoch as local time rather than UTC.
|
|
203
|
-
# If the provided string timestamp exists and differs significantly
|
|
204
|
-
# from the epoch-based time, reinterpret the epoch as local time.
|
|
205
|
-
if raw_time_str:
|
|
206
|
-
raw_norm = raw_time_str
|
|
207
|
-
if "Today" in raw_norm:
|
|
208
|
-
raw_norm = raw_norm.replace("Today", str(now_local.date()))
|
|
209
|
-
try:
|
|
210
|
-
dt_str_local = datetime.datetime.strptime(
|
|
211
|
-
raw_norm, "%Y-%m-%d %H:%M:%S"
|
|
212
|
-
).replace(tzinfo=tzinfo)
|
|
213
|
-
diff = abs(
|
|
214
|
-
(
|
|
215
|
-
event_utc
|
|
216
|
-
- dt_str_local.astimezone(datetime.UTC)
|
|
217
|
-
).total_seconds()
|
|
218
|
-
)
|
|
219
|
-
if diff > 120:
|
|
220
|
-
# Reinterpret the epoch as local clock time in camera tz
|
|
221
|
-
naive_utc = datetime.datetime.fromtimestamp(ts, tz=datetime.UTC).replace(tzinfo=None)
|
|
222
|
-
event_local_reint = naive_utc.replace(tzinfo=tzinfo)
|
|
223
|
-
event_utc = event_local_reint.astimezone(datetime.UTC)
|
|
224
|
-
last_alarm_dt = event_local_reint
|
|
225
|
-
except ValueError:
|
|
226
|
-
pass
|
|
227
|
-
except (
|
|
228
|
-
TypeError,
|
|
229
|
-
ValueError,
|
|
230
|
-
OSError,
|
|
231
|
-
): # fall back to string parsing below
|
|
232
|
-
last_alarm_dt = None
|
|
233
|
-
|
|
234
|
-
last_alarm_str: str | None = None
|
|
235
|
-
if last_alarm_dt is None:
|
|
236
|
-
# Fall back to string parsing
|
|
237
|
-
raw = raw_time_str
|
|
238
|
-
if not raw:
|
|
239
|
-
return
|
|
240
|
-
if "Today" in raw:
|
|
241
|
-
raw = raw.replace("Today", str(now_local.date()))
|
|
242
|
-
try:
|
|
243
|
-
last_alarm_dt = datetime.datetime.strptime(
|
|
244
|
-
raw, "%Y-%m-%d %H:%M:%S"
|
|
245
|
-
).replace(tzinfo=tzinfo)
|
|
246
|
-
last_alarm_str = last_alarm_dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
247
|
-
except ValueError: # Unrecognized format; give up gracefully
|
|
248
|
-
_LOGGER.debug(
|
|
249
|
-
"Unrecognized alarm time format for %s: %s", self._serial, raw
|
|
250
|
-
)
|
|
251
|
-
self._alarmmotiontrigger = {
|
|
252
|
-
"alarm_trigger_active": False,
|
|
253
|
-
"timepassed": None,
|
|
254
|
-
"last_alarm_time_str": raw or None,
|
|
255
|
-
}
|
|
256
|
-
return
|
|
257
|
-
else:
|
|
258
|
-
# We selected epoch path; format a human-readable local string
|
|
259
|
-
last_alarm_str = last_alarm_dt.astimezone(tzinfo).strftime(
|
|
260
|
-
"%Y-%m-%d %H:%M:%S"
|
|
261
|
-
)
|
|
262
|
-
|
|
263
|
-
# Compute elapsed seconds since the last alarm. If the timestamp is
|
|
264
|
-
# somehow in the future (timezone mismatch or clock skew), do not
|
|
265
|
-
# report a motion trigger; clamp the exposed seconds to 0.0.
|
|
266
|
-
# Use UTC delta when epoch was provided; otherwise compute in camera tz.
|
|
267
|
-
if epoch is not None and last_alarm_dt is not None:
|
|
268
|
-
event_utc_for_delta = last_alarm_dt.astimezone(datetime.UTC)
|
|
269
|
-
delta = now_utc - event_utc_for_delta
|
|
270
|
-
else:
|
|
271
|
-
delta = now_local - last_alarm_dt
|
|
272
|
-
seconds = float(delta.total_seconds())
|
|
273
|
-
if seconds < 0:
|
|
274
|
-
active = False
|
|
275
|
-
seconds_out = 0.0
|
|
276
|
-
else:
|
|
277
|
-
active = seconds < 60.0
|
|
278
|
-
seconds_out = seconds
|
|
279
182
|
|
|
280
183
|
self._alarmmotiontrigger = {
|
|
281
184
|
"alarm_trigger_active": active,
|
|
@@ -284,41 +187,9 @@ class EzvizCamera:
|
|
|
284
187
|
}
|
|
285
188
|
|
|
286
189
|
def _get_tzinfo(self) -> datetime.tzinfo:
|
|
287
|
-
"""Return tzinfo from camera setting if recognizable, else local tzinfo.
|
|
288
|
-
|
|
289
|
-
Attempts to parse common formats like 'UTC+02:00', 'GMT+8', '+0530', or IANA names.
|
|
290
|
-
Falls back to local timezone.
|
|
291
|
-
"""
|
|
190
|
+
"""Return tzinfo from camera setting if recognizable, else local tzinfo."""
|
|
292
191
|
tz_val = self.fetch_key(["STATUS", "optionals", "timeZone"])
|
|
293
|
-
|
|
294
|
-
if isinstance(tz_val, str) and "/" in tz_val:
|
|
295
|
-
try:
|
|
296
|
-
return ZoneInfo(tz_val)
|
|
297
|
-
except ZoneInfoNotFoundError:
|
|
298
|
-
pass
|
|
299
|
-
# Offset formats
|
|
300
|
-
offset_minutes: int | None = None
|
|
301
|
-
if isinstance(tz_val, int):
|
|
302
|
-
# Heuristic: treat small absolute values as hours, large as minutes/seconds
|
|
303
|
-
if -14 <= tz_val <= 14:
|
|
304
|
-
offset_minutes = tz_val * 60
|
|
305
|
-
elif -24 * 60 <= tz_val <= 24 * 60:
|
|
306
|
-
offset_minutes = tz_val
|
|
307
|
-
elif -24 * 3600 <= tz_val <= 24 * 3600:
|
|
308
|
-
offset_minutes = int(tz_val / 60)
|
|
309
|
-
elif isinstance(tz_val, str):
|
|
310
|
-
s = tz_val.strip().upper().replace("UTC", "").replace("GMT", "")
|
|
311
|
-
# Normalize formats like '+02:00', '+0200', '+2'
|
|
312
|
-
m = re.match(r"^([+-]?)(\d{1,2})(?::?(\d{2}))?$", s)
|
|
313
|
-
if m:
|
|
314
|
-
sign = -1 if m.group(1) == "-" else 1
|
|
315
|
-
hours = int(m.group(2))
|
|
316
|
-
minutes = int(m.group(3)) if m.group(3) else 0
|
|
317
|
-
offset_minutes = sign * (hours * 60 + minutes)
|
|
318
|
-
if offset_minutes is not None:
|
|
319
|
-
return datetime.timezone(datetime.timedelta(minutes=offset_minutes))
|
|
320
|
-
# Fallback to local timezone
|
|
321
|
-
return datetime.datetime.now().astimezone().tzinfo or datetime.UTC
|
|
192
|
+
return parse_timezone_value(tz_val)
|
|
322
193
|
|
|
323
194
|
def _is_alarm_schedules_enabled(self) -> bool:
|
|
324
195
|
"""Check if alarm schedules enabled."""
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Decrypt camera images."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
from hashlib import md5
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import re as _re
|
|
10
|
+
from typing import Any
|
|
11
|
+
import uuid
|
|
12
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
13
|
+
|
|
14
|
+
from Crypto.Cipher import AES
|
|
15
|
+
|
|
16
|
+
from .exceptions import PyEzvizError
|
|
17
|
+
|
|
18
|
+
_LOGGER = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def convert_to_dict(data: Any) -> Any:
|
|
22
|
+
"""Recursively convert a string representation of a dictionary to a dictionary."""
|
|
23
|
+
if isinstance(data, dict):
|
|
24
|
+
for key, value in data.items():
|
|
25
|
+
if isinstance(value, str):
|
|
26
|
+
try:
|
|
27
|
+
# Attempt to convert the string back into a dictionary
|
|
28
|
+
data[key] = json.loads(value)
|
|
29
|
+
|
|
30
|
+
except ValueError:
|
|
31
|
+
continue
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
return data
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def string_to_list(data: Any, separator: str = ",") -> Any:
|
|
38
|
+
"""Convert a string representation of a list to a list."""
|
|
39
|
+
if isinstance(data, str):
|
|
40
|
+
if separator in data:
|
|
41
|
+
try:
|
|
42
|
+
# Attempt to convert the string into a list
|
|
43
|
+
return data.split(separator)
|
|
44
|
+
|
|
45
|
+
except AttributeError:
|
|
46
|
+
return data
|
|
47
|
+
|
|
48
|
+
return data
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fetch_nested_value(data: Any, keys: list, default_value: Any = None) -> Any:
|
|
52
|
+
"""Fetch the value corresponding to the given nested keys in a dictionary.
|
|
53
|
+
|
|
54
|
+
If any of the keys in the path doesn't exist, the default value is returned.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
data (dict): The nested dictionary to search for keys.
|
|
58
|
+
keys (list): A list of keys representing the path to the desired value.
|
|
59
|
+
default_value (optional): The value to return if any of the keys doesn't exist.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The value corresponding to the nested keys or the default value.
|
|
63
|
+
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
for key in keys:
|
|
67
|
+
data = data[key]
|
|
68
|
+
|
|
69
|
+
except (KeyError, TypeError):
|
|
70
|
+
return default_value
|
|
71
|
+
|
|
72
|
+
return data
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def decrypt_image(input_data: bytes, password: str) -> bytes:
|
|
76
|
+
"""Decrypts image data with provided password.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
input_data (bytes): Encrypted image data
|
|
80
|
+
password (string): Verification code
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
PyEzvizError
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
bytes: Decrypted image data
|
|
87
|
+
|
|
88
|
+
"""
|
|
89
|
+
if len(input_data) < 48:
|
|
90
|
+
raise PyEzvizError("Invalid image data")
|
|
91
|
+
|
|
92
|
+
# check header
|
|
93
|
+
if input_data[:16] != b"hikencodepicture":
|
|
94
|
+
_LOGGER.debug("Image header doesn't contain 'hikencodepicture'")
|
|
95
|
+
return input_data
|
|
96
|
+
|
|
97
|
+
file_hash = input_data[16:48]
|
|
98
|
+
passwd_hash = md5(str.encode(md5(str.encode(password)).hexdigest())).hexdigest()
|
|
99
|
+
if file_hash != str.encode(passwd_hash):
|
|
100
|
+
raise PyEzvizError("Invalid password")
|
|
101
|
+
|
|
102
|
+
key = str.encode(password.ljust(16, "\u0000")[:16])
|
|
103
|
+
iv_code = bytes([48, 49, 50, 51, 52, 53, 54, 55, 0, 0, 0, 0, 0, 0, 0, 0])
|
|
104
|
+
cipher = AES.new(key, AES.MODE_CBC, iv_code)
|
|
105
|
+
|
|
106
|
+
next_chunk = b""
|
|
107
|
+
output_data = b""
|
|
108
|
+
finished = False
|
|
109
|
+
i = 48 # offset hikencodepicture + hash
|
|
110
|
+
chunk_size = 1024 * AES.block_size
|
|
111
|
+
while not finished:
|
|
112
|
+
chunk, next_chunk = next_chunk, cipher.decrypt(input_data[i : i + chunk_size])
|
|
113
|
+
if len(next_chunk) == 0:
|
|
114
|
+
padding_length = chunk[-1]
|
|
115
|
+
chunk = chunk[:-padding_length]
|
|
116
|
+
finished = True
|
|
117
|
+
output_data += chunk
|
|
118
|
+
i += chunk_size
|
|
119
|
+
return output_data
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def return_password_hash(password: str) -> str:
|
|
123
|
+
"""Return the password hash."""
|
|
124
|
+
return md5(str.encode(md5(str.encode(password)).hexdigest())).hexdigest()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def deep_merge(dict1: Any, dict2: Any) -> Any:
|
|
128
|
+
"""Recursively merges two dictionaries, handling lists as well.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
dict1 (dict): The first dictionary.
|
|
132
|
+
dict2 (dict): The second dictionary.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
dict: The merged dictionary.
|
|
136
|
+
|
|
137
|
+
"""
|
|
138
|
+
# If one of the dictionaries is None, return the other one
|
|
139
|
+
if dict1 is None:
|
|
140
|
+
return dict2
|
|
141
|
+
if dict2 is None:
|
|
142
|
+
return dict1
|
|
143
|
+
|
|
144
|
+
if not isinstance(dict1, dict) or not isinstance(dict2, dict):
|
|
145
|
+
if isinstance(dict1, list) and isinstance(dict2, list):
|
|
146
|
+
return dict1 + dict2
|
|
147
|
+
return dict2
|
|
148
|
+
|
|
149
|
+
# Create a new dictionary to store the merged result
|
|
150
|
+
merged = {}
|
|
151
|
+
|
|
152
|
+
# Merge keys from both dictionaries
|
|
153
|
+
for key in set(dict1.keys()) | set(dict2.keys()):
|
|
154
|
+
if key in dict1 and key in dict2:
|
|
155
|
+
if isinstance(dict1[key], dict) and isinstance(dict2[key], dict):
|
|
156
|
+
merged[key] = deep_merge(dict1[key], dict2[key])
|
|
157
|
+
elif isinstance(dict1[key], list) and isinstance(dict2[key], list):
|
|
158
|
+
merged[key] = dict1[key] + dict2[key]
|
|
159
|
+
else:
|
|
160
|
+
# If both values are not dictionaries or lists, keep the value from dict2
|
|
161
|
+
merged[key] = dict2[key]
|
|
162
|
+
elif key in dict1:
|
|
163
|
+
# If the key is only in dict1, keep its value
|
|
164
|
+
merged[key] = dict1[key]
|
|
165
|
+
else:
|
|
166
|
+
# If the key is only in dict2, keep its value
|
|
167
|
+
merged[key] = dict2[key]
|
|
168
|
+
|
|
169
|
+
return merged
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def generate_unique_code() -> str:
|
|
173
|
+
"""Generate a deterministic, platform-agnostic unique code for the current host.
|
|
174
|
+
|
|
175
|
+
This function retrieves the host's MAC address using Python's standard
|
|
176
|
+
`uuid.getnode()` (works on Windows, Linux, macOS), converts it to a
|
|
177
|
+
canonical string representation, and then hashes it using MD5 to produce
|
|
178
|
+
a fixed-length hexadecimal string.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
str: A 32-character hexadecimal string uniquely representing
|
|
182
|
+
the host's MAC address. For example:
|
|
183
|
+
'a94e6756hghjgfghg49e0f310d9e44a'.
|
|
184
|
+
|
|
185
|
+
Notes:
|
|
186
|
+
- The output is deterministic: the same machine returns the same code.
|
|
187
|
+
- If the MAC address changes (e.g., different network adapter),
|
|
188
|
+
the output will change.
|
|
189
|
+
- MD5 is used here only for ID generation, not for security.
|
|
190
|
+
"""
|
|
191
|
+
mac_int = uuid.getnode()
|
|
192
|
+
mac_str = ":".join(f"{(mac_int >> i) & 0xFF:02x}" for i in range(40, -1, -8))
|
|
193
|
+
return md5(mac_str.encode("utf-8")).hexdigest()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Time helpers for alarm/motion handling
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def normalize_alarm_time(
|
|
201
|
+
last_alarm: dict[str, Any], tzinfo: datetime.tzinfo
|
|
202
|
+
) -> tuple[datetime.datetime | None, datetime.datetime | None, str | None]:
|
|
203
|
+
"""Normalize EZVIZ alarm timestamps.
|
|
204
|
+
|
|
205
|
+
Returns a tuple of:
|
|
206
|
+
- alarm_dt_local: datetime in the camera's timezone (for display)
|
|
207
|
+
- alarm_dt_utc: datetime in UTC (for robust delta calculation)
|
|
208
|
+
- alarm_time_str: formatted 'YYYY-MM-DD HH:MM:SS' string in camera tz
|
|
209
|
+
|
|
210
|
+
Behavior:
|
|
211
|
+
- Prefer epoch fields (alarmStartTime/alarmTime). Interpret as UTC by default.
|
|
212
|
+
- If a string time exists and differs from the epoch by >120 seconds,
|
|
213
|
+
reinterpret the epoch as if reported in camera local time.
|
|
214
|
+
- If no epoch, fall back to parsing the string time in the camera tz.
|
|
215
|
+
"""
|
|
216
|
+
# Prefer epoch
|
|
217
|
+
epoch = last_alarm.get("alarmStartTime") or last_alarm.get("alarmTime")
|
|
218
|
+
raw_time_str = str(
|
|
219
|
+
last_alarm.get("alarmStartTimeStr") or last_alarm.get("alarmTimeStr") or ""
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
alarm_dt_local: datetime.datetime | None = None
|
|
223
|
+
alarm_dt_utc: datetime.datetime | None = None
|
|
224
|
+
alarm_str: str | None = None
|
|
225
|
+
|
|
226
|
+
now_local = datetime.datetime.now(tz=tzinfo)
|
|
227
|
+
|
|
228
|
+
if epoch is not None:
|
|
229
|
+
try:
|
|
230
|
+
ts = float(epoch if not isinstance(epoch, str) else float(epoch))
|
|
231
|
+
if ts > 1e11: # milliseconds
|
|
232
|
+
ts /= 1000.0
|
|
233
|
+
event_utc = datetime.datetime.fromtimestamp(ts, tz=datetime.UTC)
|
|
234
|
+
alarm_dt_local = event_utc.astimezone(tzinfo)
|
|
235
|
+
alarm_dt_utc = event_utc
|
|
236
|
+
|
|
237
|
+
if raw_time_str:
|
|
238
|
+
raw_norm = raw_time_str.replace("Today", str(now_local.date()))
|
|
239
|
+
try:
|
|
240
|
+
dt_str_local = datetime.datetime.strptime(
|
|
241
|
+
raw_norm, "%Y-%m-%d %H:%M:%S"
|
|
242
|
+
).replace(tzinfo=tzinfo)
|
|
243
|
+
diff = abs(
|
|
244
|
+
(event_utc - dt_str_local.astimezone(datetime.UTC)).total_seconds()
|
|
245
|
+
)
|
|
246
|
+
if diff > 120:
|
|
247
|
+
# Reinterpret epoch as local clock time in camera tz
|
|
248
|
+
naive_utc = (
|
|
249
|
+
datetime.datetime.fromtimestamp(ts, tz=datetime.UTC)
|
|
250
|
+
.replace(tzinfo=None)
|
|
251
|
+
)
|
|
252
|
+
event_local_reint = naive_utc.replace(tzinfo=tzinfo)
|
|
253
|
+
alarm_dt_local = event_local_reint
|
|
254
|
+
alarm_dt_utc = event_local_reint.astimezone(datetime.UTC)
|
|
255
|
+
except ValueError:
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
if alarm_dt_local is not None:
|
|
259
|
+
alarm_str = alarm_dt_local.strftime("%Y-%m-%d %H:%M:%S")
|
|
260
|
+
return alarm_dt_local, alarm_dt_utc, alarm_str
|
|
261
|
+
# If conversion failed unexpectedly, fall through to string parsing
|
|
262
|
+
except (TypeError, ValueError, OSError):
|
|
263
|
+
alarm_dt_local = None
|
|
264
|
+
|
|
265
|
+
# Fallback to string parsing
|
|
266
|
+
if raw_time_str:
|
|
267
|
+
raw = raw_time_str.replace("Today", str(now_local.date()))
|
|
268
|
+
try:
|
|
269
|
+
alarm_dt_local = datetime.datetime.strptime(raw, "%Y-%m-%d %H:%M:%S").replace(
|
|
270
|
+
tzinfo=tzinfo
|
|
271
|
+
)
|
|
272
|
+
alarm_dt_utc = alarm_dt_local.astimezone(datetime.UTC)
|
|
273
|
+
alarm_str = alarm_dt_local.strftime("%Y-%m-%d %H:%M:%S")
|
|
274
|
+
except ValueError:
|
|
275
|
+
pass
|
|
276
|
+
|
|
277
|
+
return alarm_dt_local, alarm_dt_utc, alarm_str
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def compute_motion_from_alarm(
|
|
281
|
+
last_alarm: dict[str, Any], tzinfo: datetime.tzinfo, window_seconds: float = 60.0
|
|
282
|
+
) -> tuple[bool, float, str | None]:
|
|
283
|
+
"""Compute motion state and seconds-since from an alarm payload.
|
|
284
|
+
|
|
285
|
+
Returns (active, seconds_since, last_alarm_time_str).
|
|
286
|
+
- Uses UTC for delta when epoch-derived UTC is available.
|
|
287
|
+
- Falls back to camera local tz deltas when only string times are present.
|
|
288
|
+
- Clamps negative deltas to 0.0 and deactivates motion.
|
|
289
|
+
"""
|
|
290
|
+
alarm_dt_local, alarm_dt_utc, alarm_str = normalize_alarm_time(last_alarm, tzinfo)
|
|
291
|
+
if alarm_dt_local is None:
|
|
292
|
+
return False, 0.0, None
|
|
293
|
+
|
|
294
|
+
now_local = datetime.datetime.now(tz=tzinfo).replace(microsecond=0)
|
|
295
|
+
now_utc = datetime.datetime.now(tz=datetime.UTC).replace(microsecond=0)
|
|
296
|
+
|
|
297
|
+
if alarm_dt_utc is not None:
|
|
298
|
+
delta = now_utc - alarm_dt_utc
|
|
299
|
+
else:
|
|
300
|
+
delta = now_local - alarm_dt_local
|
|
301
|
+
|
|
302
|
+
seconds = float(delta.total_seconds())
|
|
303
|
+
if seconds < 0:
|
|
304
|
+
return False, 0.0, alarm_str
|
|
305
|
+
|
|
306
|
+
return seconds < window_seconds, seconds, alarm_str
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def parse_timezone_value(tz_val: Any) -> datetime.tzinfo:
|
|
310
|
+
"""Parse EZVIZ timeZone value into a tzinfo.
|
|
311
|
+
|
|
312
|
+
Supports:
|
|
313
|
+
- IANA names like 'Europe/Paris'
|
|
314
|
+
- Offsets like 'UTC+02:00', 'GMT-5', '+0530', or integers (hours/minutes/seconds)
|
|
315
|
+
Falls back to the local system timezone, or UTC if unavailable.
|
|
316
|
+
"""
|
|
317
|
+
# IANA zone name
|
|
318
|
+
if isinstance(tz_val, str) and "/" in tz_val:
|
|
319
|
+
try:
|
|
320
|
+
return ZoneInfo(tz_val)
|
|
321
|
+
except ZoneInfoNotFoundError:
|
|
322
|
+
pass
|
|
323
|
+
|
|
324
|
+
# Numeric offsets
|
|
325
|
+
offset_minutes: int | None = None
|
|
326
|
+
if isinstance(tz_val, int):
|
|
327
|
+
if -14 <= tz_val <= 14:
|
|
328
|
+
offset_minutes = tz_val * 60
|
|
329
|
+
elif -24 * 60 <= tz_val <= 24 * 60:
|
|
330
|
+
offset_minutes = tz_val
|
|
331
|
+
elif -24 * 3600 <= tz_val <= 24 * 3600:
|
|
332
|
+
offset_minutes = int(tz_val / 60)
|
|
333
|
+
elif isinstance(tz_val, str):
|
|
334
|
+
s = tz_val.strip().upper().replace("UTC", "").replace("GMT", "")
|
|
335
|
+
m = _re.match(r"^([+-]?)(\d{1,2})(?::?(\d{2}))?$", s)
|
|
336
|
+
if m:
|
|
337
|
+
sign = -1 if m.group(1) == "-" else 1
|
|
338
|
+
hours = int(m.group(2))
|
|
339
|
+
minutes = int(m.group(3)) if m.group(3) else 0
|
|
340
|
+
offset_minutes = sign * (hours * 60 + minutes)
|
|
341
|
+
|
|
342
|
+
if offset_minutes is not None:
|
|
343
|
+
return datetime.timezone(datetime.timedelta(minutes=offset_minutes))
|
|
344
|
+
|
|
345
|
+
# Fallbacks
|
|
346
|
+
return datetime.datetime.now().astimezone().tzinfo or datetime.UTC
|
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
"""Decrypt camera images."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from hashlib import md5
|
|
6
|
-
import json
|
|
7
|
-
import logging
|
|
8
|
-
from typing import Any
|
|
9
|
-
import uuid
|
|
10
|
-
|
|
11
|
-
from Crypto.Cipher import AES
|
|
12
|
-
|
|
13
|
-
from .exceptions import PyEzvizError
|
|
14
|
-
|
|
15
|
-
_LOGGER = logging.getLogger(__name__)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def convert_to_dict(data: Any) -> Any:
|
|
19
|
-
"""Recursively convert a string representation of a dictionary to a dictionary."""
|
|
20
|
-
if isinstance(data, dict):
|
|
21
|
-
for key, value in data.items():
|
|
22
|
-
if isinstance(value, str):
|
|
23
|
-
try:
|
|
24
|
-
# Attempt to convert the string back into a dictionary
|
|
25
|
-
data[key] = json.loads(value)
|
|
26
|
-
|
|
27
|
-
except ValueError:
|
|
28
|
-
continue
|
|
29
|
-
continue
|
|
30
|
-
|
|
31
|
-
return data
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def string_to_list(data: Any, separator: str = ",") -> Any:
|
|
35
|
-
"""Convert a string representation of a list to a list."""
|
|
36
|
-
if isinstance(data, str):
|
|
37
|
-
if separator in data:
|
|
38
|
-
try:
|
|
39
|
-
# Attempt to convert the string into a list
|
|
40
|
-
return data.split(separator)
|
|
41
|
-
|
|
42
|
-
except AttributeError:
|
|
43
|
-
return data
|
|
44
|
-
|
|
45
|
-
return data
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def fetch_nested_value(data: Any, keys: list, default_value: Any = None) -> Any:
|
|
49
|
-
"""Fetch the value corresponding to the given nested keys in a dictionary.
|
|
50
|
-
|
|
51
|
-
If any of the keys in the path doesn't exist, the default value is returned.
|
|
52
|
-
|
|
53
|
-
Args:
|
|
54
|
-
data (dict): The nested dictionary to search for keys.
|
|
55
|
-
keys (list): A list of keys representing the path to the desired value.
|
|
56
|
-
default_value (optional): The value to return if any of the keys doesn't exist.
|
|
57
|
-
|
|
58
|
-
Returns:
|
|
59
|
-
The value corresponding to the nested keys or the default value.
|
|
60
|
-
|
|
61
|
-
"""
|
|
62
|
-
try:
|
|
63
|
-
for key in keys:
|
|
64
|
-
data = data[key]
|
|
65
|
-
|
|
66
|
-
except (KeyError, TypeError):
|
|
67
|
-
return default_value
|
|
68
|
-
|
|
69
|
-
return data
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def decrypt_image(input_data: bytes, password: str) -> bytes:
|
|
73
|
-
"""Decrypts image data with provided password.
|
|
74
|
-
|
|
75
|
-
Args:
|
|
76
|
-
input_data (bytes): Encrypted image data
|
|
77
|
-
password (string): Verification code
|
|
78
|
-
|
|
79
|
-
Raises:
|
|
80
|
-
PyEzvizError
|
|
81
|
-
|
|
82
|
-
Returns:
|
|
83
|
-
bytes: Decrypted image data
|
|
84
|
-
|
|
85
|
-
"""
|
|
86
|
-
if len(input_data) < 48:
|
|
87
|
-
raise PyEzvizError("Invalid image data")
|
|
88
|
-
|
|
89
|
-
# check header
|
|
90
|
-
if input_data[:16] != b"hikencodepicture":
|
|
91
|
-
_LOGGER.debug("Image header doesn't contain 'hikencodepicture'")
|
|
92
|
-
return input_data
|
|
93
|
-
|
|
94
|
-
file_hash = input_data[16:48]
|
|
95
|
-
passwd_hash = md5(str.encode(md5(str.encode(password)).hexdigest())).hexdigest()
|
|
96
|
-
if file_hash != str.encode(passwd_hash):
|
|
97
|
-
raise PyEzvizError("Invalid password")
|
|
98
|
-
|
|
99
|
-
key = str.encode(password.ljust(16, "\u0000")[:16])
|
|
100
|
-
iv_code = bytes([48, 49, 50, 51, 52, 53, 54, 55, 0, 0, 0, 0, 0, 0, 0, 0])
|
|
101
|
-
cipher = AES.new(key, AES.MODE_CBC, iv_code)
|
|
102
|
-
|
|
103
|
-
next_chunk = b""
|
|
104
|
-
output_data = b""
|
|
105
|
-
finished = False
|
|
106
|
-
i = 48 # offset hikencodepicture + hash
|
|
107
|
-
chunk_size = 1024 * AES.block_size
|
|
108
|
-
while not finished:
|
|
109
|
-
chunk, next_chunk = next_chunk, cipher.decrypt(input_data[i : i + chunk_size])
|
|
110
|
-
if len(next_chunk) == 0:
|
|
111
|
-
padding_length = chunk[-1]
|
|
112
|
-
chunk = chunk[:-padding_length]
|
|
113
|
-
finished = True
|
|
114
|
-
output_data += chunk
|
|
115
|
-
i += chunk_size
|
|
116
|
-
return output_data
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def return_password_hash(password: str) -> str:
|
|
120
|
-
"""Return the password hash."""
|
|
121
|
-
return md5(str.encode(md5(str.encode(password)).hexdigest())).hexdigest()
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def deep_merge(dict1: Any, dict2: Any) -> Any:
|
|
125
|
-
"""Recursively merges two dictionaries, handling lists as well.
|
|
126
|
-
|
|
127
|
-
Args:
|
|
128
|
-
dict1 (dict): The first dictionary.
|
|
129
|
-
dict2 (dict): The second dictionary.
|
|
130
|
-
|
|
131
|
-
Returns:
|
|
132
|
-
dict: The merged dictionary.
|
|
133
|
-
|
|
134
|
-
"""
|
|
135
|
-
# If one of the dictionaries is None, return the other one
|
|
136
|
-
if dict1 is None:
|
|
137
|
-
return dict2
|
|
138
|
-
if dict2 is None:
|
|
139
|
-
return dict1
|
|
140
|
-
|
|
141
|
-
if not isinstance(dict1, dict) or not isinstance(dict2, dict):
|
|
142
|
-
if isinstance(dict1, list) and isinstance(dict2, list):
|
|
143
|
-
return dict1 + dict2
|
|
144
|
-
return dict2
|
|
145
|
-
|
|
146
|
-
# Create a new dictionary to store the merged result
|
|
147
|
-
merged = {}
|
|
148
|
-
|
|
149
|
-
# Merge keys from both dictionaries
|
|
150
|
-
for key in set(dict1.keys()) | set(dict2.keys()):
|
|
151
|
-
if key in dict1 and key in dict2:
|
|
152
|
-
if isinstance(dict1[key], dict) and isinstance(dict2[key], dict):
|
|
153
|
-
merged[key] = deep_merge(dict1[key], dict2[key])
|
|
154
|
-
elif isinstance(dict1[key], list) and isinstance(dict2[key], list):
|
|
155
|
-
merged[key] = dict1[key] + dict2[key]
|
|
156
|
-
else:
|
|
157
|
-
# If both values are not dictionaries or lists, keep the value from dict2
|
|
158
|
-
merged[key] = dict2[key]
|
|
159
|
-
elif key in dict1:
|
|
160
|
-
# If the key is only in dict1, keep its value
|
|
161
|
-
merged[key] = dict1[key]
|
|
162
|
-
else:
|
|
163
|
-
# If the key is only in dict2, keep its value
|
|
164
|
-
merged[key] = dict2[key]
|
|
165
|
-
|
|
166
|
-
return merged
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def generate_unique_code() -> str:
|
|
170
|
-
"""Generate a deterministic, platform-agnostic unique code for the current host.
|
|
171
|
-
|
|
172
|
-
This function retrieves the host's MAC address using Python's standard
|
|
173
|
-
`uuid.getnode()` (works on Windows, Linux, macOS), converts it to a
|
|
174
|
-
canonical string representation, and then hashes it using MD5 to produce
|
|
175
|
-
a fixed-length hexadecimal string.
|
|
176
|
-
|
|
177
|
-
Returns:
|
|
178
|
-
str: A 32-character hexadecimal string uniquely representing
|
|
179
|
-
the host's MAC address. For example:
|
|
180
|
-
'a94e6756hghjgfghg49e0f310d9e44a'.
|
|
181
|
-
|
|
182
|
-
Notes:
|
|
183
|
-
- The output is deterministic: the same machine returns the same code.
|
|
184
|
-
- If the MAC address changes (e.g., different network adapter),
|
|
185
|
-
the output will change.
|
|
186
|
-
- MD5 is used here only for ID generation, not for security.
|
|
187
|
-
"""
|
|
188
|
-
mac_int = uuid.getnode()
|
|
189
|
-
mac_str = ":".join(f"{(mac_int >> i) & 0xFF:02x}" for i in range(40, -1, -8))
|
|
190
|
-
return md5(mac_str.encode("utf-8")).hexdigest()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|