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.

Files changed (28) hide show
  1. {pyezvizapi-1.0.2.1/pyezvizapi.egg-info → pyezvizapi-1.0.2.3}/PKG-INFO +1 -1
  2. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/camera.py +18 -90
  3. pyezvizapi-1.0.2.3/pyezvizapi/utils.py +346 -0
  4. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3/pyezvizapi.egg-info}/PKG-INFO +1 -1
  5. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/setup.py +1 -1
  6. pyezvizapi-1.0.2.1/pyezvizapi/utils.py +0 -190
  7. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/LICENSE +0 -0
  8. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/LICENSE.md +0 -0
  9. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/MANIFEST.in +0 -0
  10. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/README.md +0 -0
  11. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/__init__.py +0 -0
  12. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/__main__.py +0 -0
  13. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/api_endpoints.py +0 -0
  14. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/cas.py +0 -0
  15. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/client.py +0 -0
  16. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/constants.py +0 -0
  17. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/exceptions.py +0 -0
  18. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/light_bulb.py +0 -0
  19. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/models.py +0 -0
  20. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/mqtt.py +0 -0
  21. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/test_cam_rtsp.py +0 -0
  22. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi/test_mqtt.py +0 -0
  23. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi.egg-info/SOURCES.txt +0 -0
  24. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi.egg-info/dependency_links.txt +0 -0
  25. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi.egg-info/entry_points.txt +0 -0
  26. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi.egg-info/requires.txt +0 -0
  27. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/pyezvizapi.egg-info/top_level.txt +0 -0
  28. {pyezvizapi-1.0.2.1 → pyezvizapi-1.0.2.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyezvizapi
3
- Version: 1.0.2.1
3
+ Version: 1.0.2.3
4
4
  Summary: Pilot your Ezviz cameras
5
5
  Home-page: https://github.com/RenierM26/pyEzvizApi/
6
6
  Author: Renier Moorcroft
@@ -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 fetch_nested_value, string_to_list
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
- now = datetime.datetime.now(tz=tzinfo).replace(microsecond=0)
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": bool(timepassed < datetime.timedelta(seconds=60)),
229
- "timepassed": seconds,
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
- # IANA zone name
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
- "last_alarm_time": self._last_alarm.get("alarmStartTimeStr"),
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyezvizapi
3
- Version: 1.0.2.1
3
+ Version: 1.0.2.3
4
4
  Summary: Pilot your Ezviz cameras
5
5
  Home-page: https://github.com/RenierM26/pyEzvizApi/
6
6
  Author: Renier Moorcroft
@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
5
5
 
6
6
  setuptools.setup(
7
7
  name='pyezvizapi',
8
- version="1.0.2.1",
8
+ version="1.0.2.3",
9
9
  license='Apache Software License 2.0',
10
10
  author='Renier Moorcroft',
11
11
  author_email='RenierM26@users.github.com',
@@ -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