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.

Files changed (28) hide show
  1. {pyezvizapi-1.0.2.2/pyezvizapi.egg-info → pyezvizapi-1.0.2.3}/PKG-INFO +1 -1
  2. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/camera.py +10 -139
  3. pyezvizapi-1.0.2.3/pyezvizapi/utils.py +346 -0
  4. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3/pyezvizapi.egg-info}/PKG-INFO +1 -1
  5. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/setup.py +1 -1
  6. pyezvizapi-1.0.2.2/pyezvizapi/utils.py +0 -190
  7. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/LICENSE +0 -0
  8. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/LICENSE.md +0 -0
  9. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/MANIFEST.in +0 -0
  10. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/README.md +0 -0
  11. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/__init__.py +0 -0
  12. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/__main__.py +0 -0
  13. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/api_endpoints.py +0 -0
  14. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/cas.py +0 -0
  15. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/client.py +0 -0
  16. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/constants.py +0 -0
  17. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/exceptions.py +0 -0
  18. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/light_bulb.py +0 -0
  19. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/models.py +0 -0
  20. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/mqtt.py +0 -0
  21. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/test_cam_rtsp.py +0 -0
  22. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi/test_mqtt.py +0 -0
  23. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi.egg-info/SOURCES.txt +0 -0
  24. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi.egg-info/dependency_links.txt +0 -0
  25. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi.egg-info/entry_points.txt +0 -0
  26. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi.egg-info/requires.txt +0 -0
  27. {pyezvizapi-1.0.2.2 → pyezvizapi-1.0.2.3}/pyezvizapi.egg-info/top_level.txt +0 -0
  28. {pyezvizapi-1.0.2.2 → 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.2
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
@@ -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
- now_local = datetime.datetime.now(tz=tzinfo).replace(microsecond=0)
178
- now_utc = datetime.datetime.now(tz=datetime.UTC).replace(microsecond=0)
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
- # IANA zone name
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyezvizapi
3
- Version: 1.0.2.2
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.2",
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