pyezvizapi 1.0.2.1__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.1/pyezvizapi.egg-info → pyezvizapi-1.0.2.3}/PKG-INFO +1 -1
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/camera.py +18 -90
- pyezvizapi-1.0.2.3/pyezvizapi/utils.py +346 -0
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3/pyezvizapi.egg-info}/PKG-INFO +1 -1
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/setup.py +1 -1
- pyezvizapi-1.0.2.1/pyezvizapi/utils.py +0 -190
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/LICENSE +0 -0
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/LICENSE.md +0 -0
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/MANIFEST.in +0 -0
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/README.md +0 -0
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/__init__.py +0 -0
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/__main__.py +0 -0
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/api_endpoints.py +0 -0
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/cas.py +0 -0
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/client.py +0 -0
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/constants.py +0 -0
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/exceptions.py +0 -0
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/light_bulb.py +0 -0
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/models.py +0 -0
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/mqtt.py +0 -0
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/test_cam_rtsp.py +0 -0
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/test_mqtt.py +0 -0
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi.egg-info/SOURCES.txt +0 -0
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi.egg-info/dependency_links.txt +0 -0
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi.egg-info/entry_points.txt +0 -0
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi.egg-info/requires.txt +0 -0
- {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi.egg-info/top_level.txt +0 -0
- {pyezvizapi-1.0.2.1 → 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
|
|
@@ -99,6 +102,7 @@ class EzvizCamera:
|
|
|
99
102
|
self._alarmmotiontrigger: dict[str, Any] = {
|
|
100
103
|
"alarm_trigger_active": False,
|
|
101
104
|
"timepassed": None,
|
|
105
|
+
"last_alarm_time_str": None,
|
|
102
106
|
}
|
|
103
107
|
self._record: EzvizDeviceRecord | None = None
|
|
104
108
|
|
|
@@ -171,100 +175,21 @@ class EzvizCamera:
|
|
|
171
175
|
|
|
172
176
|
Prefer numeric epoch fields if available to avoid parsing localized strings.
|
|
173
177
|
"""
|
|
174
|
-
# Use timezone-aware datetimes based on camera or local timezone.
|
|
175
178
|
tzinfo = self._get_tzinfo()
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
# Prefer epoch fields if available
|
|
179
|
-
epoch = self._last_alarm.get("alarmStartTime") or self._last_alarm.get(
|
|
180
|
-
"alarmTime"
|
|
179
|
+
active, seconds_out, last_alarm_str = compute_motion_from_alarm(
|
|
180
|
+
self._last_alarm, tzinfo
|
|
181
181
|
)
|
|
182
|
-
last_alarm_dt: datetime.datetime | None = None
|
|
183
|
-
if epoch is not None:
|
|
184
|
-
try:
|
|
185
|
-
# Accept int/float/str; auto-detect ms vs s
|
|
186
|
-
if isinstance(epoch, str):
|
|
187
|
-
epoch = float(epoch)
|
|
188
|
-
ts = float(epoch)
|
|
189
|
-
if ts > 1e11: # very likely milliseconds
|
|
190
|
-
ts = ts / 1000.0
|
|
191
|
-
last_alarm_dt = datetime.datetime.fromtimestamp(ts, tz=tzinfo)
|
|
192
|
-
except (
|
|
193
|
-
TypeError,
|
|
194
|
-
ValueError,
|
|
195
|
-
OSError,
|
|
196
|
-
): # fall back to string parsing below
|
|
197
|
-
last_alarm_dt = None
|
|
198
|
-
|
|
199
|
-
if last_alarm_dt is None:
|
|
200
|
-
# Fall back to string parsing
|
|
201
|
-
raw = str(
|
|
202
|
-
self._last_alarm.get("alarmStartTimeStr")
|
|
203
|
-
or self._last_alarm.get("alarmTimeStr")
|
|
204
|
-
or ""
|
|
205
|
-
)
|
|
206
|
-
if not raw:
|
|
207
|
-
return
|
|
208
|
-
if "Today" in raw:
|
|
209
|
-
raw = raw.replace("Today", str(now.date()))
|
|
210
|
-
try:
|
|
211
|
-
last_alarm_dt = datetime.datetime.strptime(
|
|
212
|
-
raw, "%Y-%m-%d %H:%M:%S"
|
|
213
|
-
).replace(tzinfo=tzinfo)
|
|
214
|
-
except ValueError: # Unrecognized format; give up gracefully
|
|
215
|
-
_LOGGER.debug(
|
|
216
|
-
"Unrecognized alarm time format for %s: %s", self._serial, raw
|
|
217
|
-
)
|
|
218
|
-
self._alarmmotiontrigger = {
|
|
219
|
-
"alarm_trigger_active": False,
|
|
220
|
-
"timepassed": None,
|
|
221
|
-
}
|
|
222
|
-
return
|
|
223
|
-
|
|
224
|
-
timepassed = now - last_alarm_dt
|
|
225
|
-
seconds = max(0.0, timepassed.total_seconds()) if timepassed else None
|
|
226
182
|
|
|
227
183
|
self._alarmmotiontrigger = {
|
|
228
|
-
"alarm_trigger_active":
|
|
229
|
-
"timepassed":
|
|
184
|
+
"alarm_trigger_active": active,
|
|
185
|
+
"timepassed": seconds_out,
|
|
186
|
+
"last_alarm_time_str": last_alarm_str,
|
|
230
187
|
}
|
|
231
188
|
|
|
232
189
|
def _get_tzinfo(self) -> datetime.tzinfo:
|
|
233
|
-
"""Return tzinfo from camera setting if recognizable, else local tzinfo.
|
|
234
|
-
|
|
235
|
-
Attempts to parse common formats like 'UTC+02:00', 'GMT+8', '+0530', or IANA names.
|
|
236
|
-
Falls back to local timezone.
|
|
237
|
-
"""
|
|
190
|
+
"""Return tzinfo from camera setting if recognizable, else local tzinfo."""
|
|
238
191
|
tz_val = self.fetch_key(["STATUS", "optionals", "timeZone"])
|
|
239
|
-
|
|
240
|
-
if isinstance(tz_val, str) and "/" in tz_val:
|
|
241
|
-
try:
|
|
242
|
-
return ZoneInfo(tz_val)
|
|
243
|
-
except ZoneInfoNotFoundError:
|
|
244
|
-
pass
|
|
245
|
-
# Offset formats
|
|
246
|
-
offset_minutes: int | None = None
|
|
247
|
-
if isinstance(tz_val, int):
|
|
248
|
-
# Heuristic: treat small absolute values as hours, large as minutes/seconds
|
|
249
|
-
if -14 <= tz_val <= 14:
|
|
250
|
-
offset_minutes = tz_val * 60
|
|
251
|
-
elif -24 * 60 <= tz_val <= 24 * 60:
|
|
252
|
-
offset_minutes = tz_val
|
|
253
|
-
elif -24 * 3600 <= tz_val <= 24 * 3600:
|
|
254
|
-
offset_minutes = int(tz_val / 60)
|
|
255
|
-
elif isinstance(tz_val, str):
|
|
256
|
-
s = tz_val.strip().upper().replace("UTC", "").replace("GMT", "")
|
|
257
|
-
# Normalize formats like '+02:00', '+0200', '+2'
|
|
258
|
-
m = re.match(r"^([+-]?)(\d{1,2})(?::?(\d{2}))?$", s)
|
|
259
|
-
if m:
|
|
260
|
-
sign = -1 if m.group(1) == "-" else 1
|
|
261
|
-
hours = int(m.group(2))
|
|
262
|
-
minutes = int(m.group(3)) if m.group(3) else 0
|
|
263
|
-
offset_minutes = sign * (hours * 60 + minutes)
|
|
264
|
-
if offset_minutes is not None:
|
|
265
|
-
return datetime.timezone(datetime.timedelta(minutes=offset_minutes))
|
|
266
|
-
# Fallback to local timezone
|
|
267
|
-
return datetime.datetime.now().astimezone().tzinfo or datetime.UTC
|
|
192
|
+
return parse_timezone_value(tz_val)
|
|
268
193
|
|
|
269
194
|
def _is_alarm_schedules_enabled(self) -> bool:
|
|
270
195
|
"""Check if alarm schedules enabled."""
|
|
@@ -370,7 +295,10 @@ class EzvizCamera:
|
|
|
370
295
|
"PIR_Status": self.fetch_key(["STATUS", "pirStatus"]),
|
|
371
296
|
"Motion_Trigger": self._alarmmotiontrigger["alarm_trigger_active"],
|
|
372
297
|
"Seconds_Last_Trigger": self._alarmmotiontrigger["timepassed"],
|
|
373
|
-
|
|
298
|
+
# Keep last_alarm_time in sync with the time actually used to
|
|
299
|
+
# compute Motion_Trigger/Seconds_Last_Trigger.
|
|
300
|
+
"last_alarm_time": self._alarmmotiontrigger.get("last_alarm_time_str")
|
|
301
|
+
or self._last_alarm.get("alarmStartTimeStr"),
|
|
374
302
|
"last_alarm_pic": self._last_alarm.get(
|
|
375
303
|
"picUrl",
|
|
376
304
|
"https://eustatics.ezvizlife.com/ovs_mall/web/img/index/EZVIZ_logo.png?ver=3007907502",
|
|
@@ -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
|