pyezvizapi 1.0.2.2__py3-none-any.whl → 1.0.2.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pyezvizapi might be problematic. Click here for more details.

pyezvizapi/__main__.py CHANGED
@@ -26,7 +26,9 @@ _LOGGER = logging.getLogger(__name__)
26
26
  def _setup_logging(debug: bool) -> None:
27
27
  """Configure root logger for CLI usage."""
28
28
  level = logging.DEBUG if debug else logging.INFO
29
- logging.basicConfig(level=level, stream=sys.stderr, format="%(levelname)s: %(message)s")
29
+ logging.basicConfig(
30
+ level=level, stream=sys.stderr, format="%(levelname)s: %(message)s"
31
+ )
30
32
  if debug:
31
33
  # Verbose requests logging in debug mode
32
34
  requests_log = logging.getLogger("requests.packages.urllib3")
@@ -94,7 +96,7 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
94
96
  type=str,
95
97
  default="status",
96
98
  help="Light bulbs action to perform",
97
- choices=["status"]
99
+ choices=["status"],
98
100
  )
99
101
  parser_device_lights.add_argument(
100
102
  "--refresh",
@@ -125,7 +127,9 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
125
127
 
126
128
  subparsers_camera = parser_camera.add_subparsers(dest="camera_action")
127
129
 
128
- parser_camera_status = subparsers_camera.add_parser("status", help="Get the status of the camera")
130
+ parser_camera_status = subparsers_camera.add_parser(
131
+ "status", help="Get the status of the camera"
132
+ )
129
133
  parser_camera_status.add_argument(
130
134
  "--refresh",
131
135
  action=argparse.BooleanOptionalAction,
@@ -229,14 +233,19 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
229
233
  )
230
234
 
231
235
  parser_camera_select = subparsers_camera.add_parser(
232
- "select", help="Change the value of a multi-value option (for on/off value, see 'switch' command)"
236
+ "select",
237
+ help="Change the value of a multi-value option (for on/off value, see 'switch' command)",
233
238
  )
234
239
 
235
240
  parser_camera_select.add_argument(
236
241
  "--battery_work_mode",
237
242
  required=False,
238
243
  help="Change the work mode for battery powered camera",
239
- choices=[mode.name for mode in BatteryCameraWorkMode if mode is not BatteryCameraWorkMode.UNKNOWN],
244
+ choices=[
245
+ mode.name
246
+ for mode in BatteryCameraWorkMode
247
+ if mode is not BatteryCameraWorkMode.UNKNOWN
248
+ ],
240
249
  )
241
250
 
242
251
  # Dump full pagelist for exploration
@@ -244,7 +253,8 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
244
253
 
245
254
  # Dump device infos mapping (optionally for a single serial)
246
255
  parser_device_infos = subparsers.add_parser(
247
- "device_infos", help="Output device infos (raw JSON), optionally filtered by serial"
256
+ "device_infos",
257
+ help="Output device infos (raw JSON), optionally filtered by serial",
248
258
  )
249
259
  parser_device_infos.add_argument(
250
260
  "--serial", required=False, help="Optional serial to filter a single device"
@@ -418,7 +428,11 @@ def _handle_pagelist(client: EzvizClient) -> int:
418
428
 
419
429
  def _handle_device_infos(args: argparse.Namespace, client: EzvizClient) -> int:
420
430
  """Output device infos mapping (raw JSON), optionally filtered by serial."""
421
- data = client.get_device_infos(args.serial) if args.serial else client.get_device_infos()
431
+ data = (
432
+ client.get_device_infos(args.serial)
433
+ if args.serial
434
+ else client.get_device_infos()
435
+ )
422
436
  _write_json(data)
423
437
  return 0
424
438
 
@@ -535,7 +549,10 @@ def _load_token_file(path: str | None) -> dict[str, Any] | None:
535
549
  return None
536
550
  try:
537
551
  return cast(dict[str, Any], json.loads(p.read_text(encoding="utf-8")))
538
- except (OSError, json.JSONDecodeError): # pragma: no cover - tolerate malformed file
552
+ except (
553
+ OSError,
554
+ json.JSONDecodeError,
555
+ ): # pragma: no cover - tolerate malformed file
539
556
  _LOGGER.warning("Failed to read token file: %s", p)
540
557
  return None
541
558
 
pyezvizapi/camera.py CHANGED
@@ -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."""
pyezvizapi/cas.py CHANGED
@@ -38,7 +38,9 @@ class EzvizCAS:
38
38
  "api_url": "apiieu.ezvizlife.com",
39
39
  }
40
40
  if not token or "service_urls" not in token:
41
- raise PyEzvizError("Missing service_urls in token; call EzvizClient.login() first")
41
+ raise PyEzvizError(
42
+ "Missing service_urls in token; call EzvizClient.login() first"
43
+ )
42
44
  self._service_urls: dict[str, Any] = token["service_urls"]
43
45
 
44
46
  def cas_get_encryption(self, devserial: str) -> dict[str, Any]:
@@ -53,7 +55,7 @@ class EzvizCAS:
53
55
  f"\x01" # Check or order?
54
56
  f"\x00\x00\x00\x00\x00\x00\x02\t\x00\x00\x00\x00"
55
57
  f'<?xml version="1.0" encoding="utf-8"?>\n<Request>\n\t'
56
- f'<ClientID>{self._token["session_id"]}</ClientID>'
58
+ f"<ClientID>{self._token['session_id']}</ClientID>"
57
59
  f"\n\t<Sign>{FEATURE_CODE}</Sign>\n\t"
58
60
  f"<DevSerial>{devserial}</DevSerial>"
59
61
  f"\n\t<ClientType>0</ClientType>\n</Request>\n"
pyezvizapi/client.py CHANGED
@@ -167,7 +167,7 @@ class EzvizClient:
167
167
  DeviceCatagories.BASE_STATION_DEVICE_CATEGORY.value,
168
168
  DeviceCatagories.CAT_EYE_CATEGORY.value,
169
169
  DeviceCatagories.LIGHTING.value,
170
- DeviceCatagories.W2H_BASE_STATION_DEVICE_CATEGORY.value, # @emeric699 Adding support for W2H Base Station
170
+ DeviceCatagories.W2H_BASE_STATION_DEVICE_CATEGORY.value,
171
171
  ]
172
172
 
173
173
  def __init__(
@@ -189,7 +189,8 @@ class EzvizClient:
189
189
  self._session.headers["sessionId"] = str(token["session_id"]) # ensure str
190
190
  self._token: ClientToken = cast(
191
191
  ClientToken,
192
- token or {
192
+ token
193
+ or {
193
194
  "session_id": None,
194
195
  "rf_session_id": None,
195
196
  "username": None,
@@ -262,7 +263,7 @@ class EzvizClient:
262
263
  if json_result["meta"]["code"] == 1100:
263
264
  self._token["api_url"] = json_result["loginArea"]["apiDomain"]
264
265
  _LOGGER.warning(
265
- "region_incorrect: serial=%s code=%s msg=%s",
266
+ "Region_incorrect: serial=%s code=%s msg=%s",
266
267
  "unknown",
267
268
  1100,
268
269
  self._token["api_url"],
@@ -319,7 +320,11 @@ class EzvizClient:
319
320
  )
320
321
  req.raise_for_status()
321
322
  except requests.HTTPError as err:
322
- if retry_401 and err.response is not None and err.response.status_code == 401:
323
+ if (
324
+ retry_401
325
+ and err.response is not None
326
+ and err.response.status_code == 401
327
+ ):
323
328
  if max_retries >= MAX_RETRIES:
324
329
  raise HTTPError from err
325
330
  # Re-login and retry once
@@ -344,7 +349,10 @@ class EzvizClient:
344
349
  return cast(dict, resp.json())
345
350
  except ValueError as err:
346
351
  raise PyEzvizError(
347
- "Impossible to decode response: " + str(err) + "\nResponse was: " + str(resp.text)
352
+ "Impossible to decode response: "
353
+ + str(err)
354
+ + "\nResponse was: "
355
+ + str(resp.text)
348
356
  ) from err
349
357
 
350
358
  @staticmethod
@@ -413,11 +421,17 @@ class EzvizClient:
413
421
  req = self._session.send(request=prepared, timeout=self._timeout)
414
422
  req.raise_for_status()
415
423
  except requests.HTTPError as err:
416
- if retry_401 and err.response is not None and err.response.status_code == 401:
424
+ if (
425
+ retry_401
426
+ and err.response is not None
427
+ and err.response.status_code == 401
428
+ ):
417
429
  if max_retries >= MAX_RETRIES:
418
430
  raise HTTPError from err
419
431
  self.login()
420
- return self._send_prepared(prepared, retry_401=retry_401, max_retries=max_retries + 1)
432
+ return self._send_prepared(
433
+ prepared, retry_401=retry_401, max_retries=max_retries + 1
434
+ )
421
435
  raise HTTPError from err
422
436
  return req
423
437
 
@@ -478,7 +492,7 @@ class EzvizClient:
478
492
  # Prefer modern meta.code; fall back to legacy resultCode
479
493
  code = self._response_code(payload)
480
494
  _LOGGER.warning(
481
- "http_retry: serial=%s code=%s msg=%s",
495
+ "Http_retry: serial=%s code=%s msg=%s",
482
496
  serial or "unknown",
483
497
  code,
484
498
  log,
@@ -549,7 +563,7 @@ class EzvizClient:
549
563
  # session is wrong, need to relogin and retry
550
564
  self.login()
551
565
  _LOGGER.warning(
552
- "http_retry: serial=%s code=%s msg=%s",
566
+ "Http_retry: serial=%s code=%s msg=%s",
553
567
  "unknown",
554
568
  self._meta_code(json_output),
555
569
  "pagelist_relogin",
@@ -673,7 +687,11 @@ class EzvizClient:
673
687
  json_output = self._request_json(
674
688
  "PUT",
675
689
  f"{API_ENDPOINT_DEVICES}{serial}{API_ENDPOINT_SWITCH_OTHER}",
676
- params={"channelNo": channel_number, "enable": enable, "switchType": status_type},
690
+ params={
691
+ "channelNo": channel_number,
692
+ "enable": enable,
693
+ "switchType": status_type,
694
+ },
677
695
  retry_401=True,
678
696
  max_retries=max_retries,
679
697
  )
@@ -704,7 +722,9 @@ class EzvizClient:
704
722
  serial=serial,
705
723
  )
706
724
  if self._meta_code(json_output) != 200:
707
- raise PyEzvizError(f"Could not arm or disarm Camera {serial}: Got {json_output})")
725
+ raise PyEzvizError(
726
+ f"Could not arm or disarm Camera {serial}: Got {json_output})"
727
+ )
708
728
  return True
709
729
 
710
730
  def set_battery_camera_work_mode(self, serial: str, value: int) -> bool:
@@ -1017,9 +1037,14 @@ class EzvizClient:
1017
1037
  self._light_bulbs[device] = EzvizLightBulb(
1018
1038
  self, device, dict(rec.raw)
1019
1039
  ).status()
1020
- except (PyEzvizError, KeyError, TypeError, ValueError) as err: # pragma: no cover - defensive
1040
+ except (
1041
+ PyEzvizError,
1042
+ KeyError,
1043
+ TypeError,
1044
+ ValueError,
1045
+ ) as err: # pragma: no cover - defensive
1021
1046
  _LOGGER.warning(
1022
- "load_device_failed: serial=%s code=%s msg=%s",
1047
+ "Load_device_failed: serial=%s code=%s msg=%s",
1023
1048
  device,
1024
1049
  "load_error",
1025
1050
  str(err),
@@ -1029,9 +1054,14 @@ class EzvizClient:
1029
1054
  # Create camera object
1030
1055
  cam = EzvizCamera(self, device, dict(rec.raw))
1031
1056
  self._cameras[device] = cam.status(refresh=refresh)
1032
- except (PyEzvizError, KeyError, TypeError, ValueError) as err: # pragma: no cover - defensive
1057
+ except (
1058
+ PyEzvizError,
1059
+ KeyError,
1060
+ TypeError,
1061
+ ValueError,
1062
+ ) as err: # pragma: no cover - defensive
1033
1063
  _LOGGER.warning(
1034
- "load_device_failed: serial=%s code=%s msg=%s",
1064
+ "Load_device_failed: serial=%s code=%s msg=%s",
1035
1065
  device,
1036
1066
  "load_error",
1037
1067
  str(err),
@@ -1100,7 +1130,9 @@ class EzvizClient:
1100
1130
  try:
1101
1131
  support_ext = result[_serial].get("deviceInfos", {}).get("supportExt")
1102
1132
  if isinstance(support_ext, str) and support_ext:
1103
- result[_serial]["deviceInfos"]["supportExt"] = json.loads(support_ext)
1133
+ result[_serial]["deviceInfos"]["supportExt"] = json.loads(
1134
+ support_ext
1135
+ )
1104
1136
  except (TypeError, ValueError):
1105
1137
  # Leave as-is if not valid JSON
1106
1138
  pass
@@ -1199,14 +1231,16 @@ class EzvizClient:
1199
1231
 
1200
1232
  code = str(json_output.get("resultCode"))
1201
1233
  if code == "20002":
1202
- raise EzvizAuthVerificationCode(f"MFA code required: Got {json_output})")
1234
+ raise EzvizAuthVerificationCode(
1235
+ f"MFA code required: Got {json_output})"
1236
+ )
1203
1237
  if code == "2009":
1204
1238
  raise DeviceException(f"Device not reachable: Got {json_output})")
1205
1239
  if code == "0":
1206
1240
  return json_output.get("encryptkey")
1207
1241
  if code == "-1" and attempt < attempts:
1208
1242
  _LOGGER.warning(
1209
- "http_retry: serial=%s code=%s msg=%s",
1243
+ "Http_retry: serial=%s code=%s msg=%s",
1210
1244
  serial,
1211
1245
  code,
1212
1246
  "cam_key_not_found",
@@ -1357,7 +1391,9 @@ class EzvizClient:
1357
1391
  raise PyEzvizError(
1358
1392
  f"Could not send command to create panoramic photo: Got {json_output})"
1359
1393
  )
1360
- raise PyEzvizError("Could not send command to create panoramic photo: exceeded retries")
1394
+ raise PyEzvizError(
1395
+ "Could not send command to create panoramic photo: exceeded retries"
1396
+ )
1361
1397
 
1362
1398
  def return_panoramic(self, serial: str, max_retries: int = 0) -> Any:
1363
1399
  """Return panoramic image url list."""
@@ -1526,7 +1562,7 @@ class EzvizClient:
1526
1562
  except requests.HTTPError as err:
1527
1563
  if err.response.status_code == 401:
1528
1564
  _LOGGER.warning(
1529
- "http_warning: serial=%s code=%s msg=%s",
1565
+ "Http_warning: serial=%s code=%s msg=%s",
1530
1566
  "unknown",
1531
1567
  401,
1532
1568
  "logout_already_invalid",
@@ -1678,7 +1714,9 @@ class EzvizClient:
1678
1714
  else:
1679
1715
  raise PyEzvizError(f"Invalid action '{action}'. Use 'add' or 'remove'.")
1680
1716
 
1681
- json_output = self._request_json(method, url_path, retry_401=True, max_retries=max_retries)
1717
+ json_output = self._request_json(
1718
+ method, url_path, retry_401=True, max_retries=max_retries
1719
+ )
1682
1720
  self._ensure_ok(json_output, f"Could not {action} intelligent app")
1683
1721
 
1684
1722
  return True
pyezvizapi/constants.py CHANGED
@@ -464,4 +464,4 @@ class DeviceCatagories(Enum):
464
464
  BASE_STATION_DEVICE_CATEGORY = "XVR"
465
465
  CAT_EYE_CATEGORY = "CatEye"
466
466
  LIGHTING = "lighting"
467
- W2H_BASE_STATION_DEVICE_CATEGORY = "IGateWay" # @emeric699 Adding support for W2H Base Station
467
+ W2H_BASE_STATION_DEVICE_CATEGORY = "IGateWay"
@@ -22,6 +22,7 @@ def genmsg_describe(url: str, seq: int, user_agent: str, auth_seq: str) -> str:
22
22
 
23
23
  class RTSPDetails(TypedDict):
24
24
  """Typed structure for RTSP test parameters."""
25
+
25
26
  bufLen: int
26
27
  defaultServerIp: str
27
28
  defaultServerPort: int
@@ -37,7 +38,11 @@ class TestRTSPAuth:
37
38
  _rtsp_details: RTSPDetails
38
39
 
39
40
  def __init__(
40
- self, ip_addr: str, username: str | None = None, password: str | None = None, test_uri: str = ""
41
+ self,
42
+ ip_addr: str,
43
+ username: str | None = None,
44
+ password: str | None = None,
45
+ test_uri: str = "",
41
46
  ) -> None:
42
47
  """Initialize RTSP credential test."""
43
48
  self._rtsp_details = RTSPDetails(
@@ -50,7 +55,9 @@ class TestRTSPAuth:
50
55
  defaultPassword=password,
51
56
  )
52
57
 
53
- def generate_auth_string(self, realm: bytes, method: str, uri: str, nonce: bytes) -> str:
58
+ def generate_auth_string(
59
+ self, realm: bytes, method: str, uri: str, nonce: bytes
60
+ ) -> str:
54
61
  """Generate the HTTP Digest Authorization header value."""
55
62
  m_1 = hashlib.md5(
56
63
  f"{self._rtsp_details['defaultUsername']}:{realm.decode()}:{self._rtsp_details['defaultPassword']}".encode()
@@ -60,12 +67,12 @@ class TestRTSPAuth:
60
67
 
61
68
  return (
62
69
  "Digest "
63
- f"username=\"{self._rtsp_details['defaultUsername']}\", "
64
- f"realm=\"{realm.decode()}\", "
70
+ f'username="{self._rtsp_details["defaultUsername"]}", '
71
+ f'realm="{realm.decode()}", '
65
72
  'algorithm="MD5", '
66
- f"nonce=\"{nonce.decode()}\", "
67
- f"uri=\"{uri}\", "
68
- f"response=\"{response}\""
73
+ f'nonce="{nonce.decode()}", '
74
+ f'uri="{uri}", '
75
+ f'response="{response}"'
69
76
  )
70
77
 
71
78
  def main(self) -> None:
@@ -74,7 +81,10 @@ class TestRTSPAuth:
74
81
 
75
82
  try:
76
83
  session.connect(
77
- (self._rtsp_details["defaultServerIp"], self._rtsp_details["defaultServerPort"])
84
+ (
85
+ self._rtsp_details["defaultServerIp"],
86
+ self._rtsp_details["defaultServerPort"],
87
+ )
78
88
  )
79
89
  except TimeoutError as err:
80
90
  raise AuthTestResultFailed("Invalid ip or camera hibernating") from err
@@ -83,15 +93,23 @@ class TestRTSPAuth:
83
93
 
84
94
  seq: int = 1
85
95
 
86
- url: str = "rtsp://" + self._rtsp_details["defaultServerIp"] + self._rtsp_details["defaultTestUri"]
96
+ url: str = (
97
+ "rtsp://"
98
+ + self._rtsp_details["defaultServerIp"]
99
+ + self._rtsp_details["defaultTestUri"]
100
+ )
87
101
 
88
102
  # Basic Authorization header
89
103
  auth_b64: bytes = base64.b64encode(
90
- f"{self._rtsp_details['defaultUsername']}:{self._rtsp_details['defaultPassword']}".encode("ascii")
104
+ f"{self._rtsp_details['defaultUsername']}:{self._rtsp_details['defaultPassword']}".encode(
105
+ "ascii"
106
+ )
91
107
  )
92
108
  auth_seq: str = "Basic " + auth_b64.decode()
93
109
 
94
- describe = genmsg_describe(url, seq, self._rtsp_details["defaultUserAgent"], auth_seq)
110
+ describe = genmsg_describe(
111
+ url, seq, self._rtsp_details["defaultUserAgent"], auth_seq
112
+ )
95
113
  print(describe)
96
114
  session.send(describe.encode())
97
115
  msg1: bytes = session.recv(self._rtsp_details["bufLen"])
@@ -115,9 +133,13 @@ class TestRTSPAuth:
115
133
  end = decoded.find('"', begin + 1)
116
134
  nonce: bytes = msg1[begin + 1 : end]
117
135
 
118
- auth_seq = self.generate_auth_string(realm, "DESCRIBE", self._rtsp_details["defaultTestUri"], nonce)
136
+ auth_seq = self.generate_auth_string(
137
+ realm, "DESCRIBE", self._rtsp_details["defaultTestUri"], nonce
138
+ )
119
139
 
120
- describe = genmsg_describe(url, seq, self._rtsp_details["defaultUserAgent"], auth_seq)
140
+ describe = genmsg_describe(
141
+ url, seq, self._rtsp_details["defaultUserAgent"], auth_seq
142
+ )
121
143
  print(describe)
122
144
  session.send(describe.encode())
123
145
  msg1 = session.recv(self._rtsp_details["bufLen"])
@@ -132,4 +154,6 @@ class TestRTSPAuth:
132
154
  raise AuthTestResultFailed("Credentials not valid!!")
133
155
 
134
156
  print("Basic Auth test passed. Credentials Valid!")
157
+
158
+
135
159
  # ruff: noqa: T201
pyezvizapi/utils.py CHANGED
@@ -2,11 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import datetime
5
6
  from hashlib import md5
6
7
  import json
7
8
  import logging
9
+ import re as _re
8
10
  from typing import Any
9
11
  import uuid
12
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
10
13
 
11
14
  from Crypto.Cipher import AES
12
15
 
@@ -188,3 +191,158 @@ def generate_unique_code() -> str:
188
191
  mac_int = uuid.getnode()
189
192
  mac_str = ":".join(f"{(mac_int >> i) & 0xFF:02x}" for i in range(40, -1, -8))
190
193
  return md5(mac_str.encode("utf-8")).hexdigest()
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Time helpers for alarm/motion handling
198
+ # ---------------------------------------------------------------------------
199
+
200
+
201
+ def normalize_alarm_time(
202
+ last_alarm: dict[str, Any], tzinfo: datetime.tzinfo
203
+ ) -> tuple[datetime.datetime | None, datetime.datetime | None, str | None]:
204
+ """Normalize EZVIZ alarm timestamps.
205
+
206
+ Returns a tuple of:
207
+ - alarm_dt_local: datetime in the camera's timezone (for display)
208
+ - alarm_dt_utc: datetime in UTC (for robust delta calculation)
209
+ - alarm_time_str: formatted 'YYYY-MM-DD HH:MM:SS' string in camera tz
210
+
211
+ Behavior:
212
+ - Prefer epoch fields (alarmStartTime/alarmTime). Interpret as UTC by default.
213
+ - If a string time exists and differs from the epoch by >120 seconds,
214
+ reinterpret the epoch as if reported in camera local time.
215
+ - If no epoch, fall back to parsing the string time in the camera tz.
216
+ """
217
+ # Prefer epoch
218
+ epoch = last_alarm.get("alarmStartTime") or last_alarm.get("alarmTime")
219
+ raw_time_str = str(
220
+ last_alarm.get("alarmStartTimeStr") or last_alarm.get("alarmTimeStr") or ""
221
+ )
222
+
223
+ alarm_dt_local: datetime.datetime | None = None
224
+ alarm_dt_utc: datetime.datetime | None = None
225
+ alarm_str: str | None = None
226
+
227
+ now_local = datetime.datetime.now(tz=tzinfo)
228
+
229
+ if epoch is not None:
230
+ try:
231
+ ts = float(epoch if not isinstance(epoch, str) else float(epoch))
232
+ if ts > 1e11: # milliseconds
233
+ ts /= 1000.0
234
+ event_utc = datetime.datetime.fromtimestamp(ts, tz=datetime.UTC)
235
+ alarm_dt_local = event_utc.astimezone(tzinfo)
236
+ alarm_dt_utc = event_utc
237
+
238
+ if raw_time_str:
239
+ raw_norm = raw_time_str.replace("Today", str(now_local.date()))
240
+ try:
241
+ dt_str_local = datetime.datetime.strptime(
242
+ raw_norm, "%Y-%m-%d %H:%M:%S"
243
+ ).replace(tzinfo=tzinfo)
244
+ diff = abs(
245
+ (
246
+ event_utc - dt_str_local.astimezone(datetime.UTC)
247
+ ).total_seconds()
248
+ )
249
+ if diff > 120:
250
+ # Reinterpret epoch as local clock time in camera tz
251
+ naive_utc = datetime.datetime.fromtimestamp(
252
+ ts, tz=datetime.UTC
253
+ ).replace(tzinfo=None)
254
+ event_local_reint = naive_utc.replace(tzinfo=tzinfo)
255
+ alarm_dt_local = event_local_reint
256
+ alarm_dt_utc = event_local_reint.astimezone(datetime.UTC)
257
+ except ValueError:
258
+ pass
259
+
260
+ if alarm_dt_local is not None:
261
+ alarm_str = alarm_dt_local.strftime("%Y-%m-%d %H:%M:%S")
262
+ return alarm_dt_local, alarm_dt_utc, alarm_str
263
+ # If conversion failed unexpectedly, fall through to string parsing
264
+ except (TypeError, ValueError, OSError):
265
+ alarm_dt_local = None
266
+
267
+ # Fallback to string parsing
268
+ if raw_time_str:
269
+ raw = raw_time_str.replace("Today", str(now_local.date()))
270
+ try:
271
+ alarm_dt_local = datetime.datetime.strptime(
272
+ raw, "%Y-%m-%d %H:%M:%S"
273
+ ).replace(tzinfo=tzinfo)
274
+ alarm_dt_utc = alarm_dt_local.astimezone(datetime.UTC)
275
+ alarm_str = alarm_dt_local.strftime("%Y-%m-%d %H:%M:%S")
276
+ except ValueError:
277
+ pass
278
+
279
+ return alarm_dt_local, alarm_dt_utc, alarm_str
280
+
281
+
282
+ def compute_motion_from_alarm(
283
+ last_alarm: dict[str, Any], tzinfo: datetime.tzinfo, window_seconds: float = 60.0
284
+ ) -> tuple[bool, float, str | None]:
285
+ """Compute motion state and seconds-since from an alarm payload.
286
+
287
+ Returns (active, seconds_since, last_alarm_time_str).
288
+ - Uses UTC for delta when epoch-derived UTC is available.
289
+ - Falls back to camera local tz deltas when only string times are present.
290
+ - Clamps negative deltas to 0.0 and deactivates motion.
291
+ """
292
+ alarm_dt_local, alarm_dt_utc, alarm_str = normalize_alarm_time(last_alarm, tzinfo)
293
+ if alarm_dt_local is None:
294
+ return False, 0.0, None
295
+
296
+ now_local = datetime.datetime.now(tz=tzinfo).replace(microsecond=0)
297
+ now_utc = datetime.datetime.now(tz=datetime.UTC).replace(microsecond=0)
298
+
299
+ if alarm_dt_utc is not None:
300
+ delta = now_utc - alarm_dt_utc
301
+ else:
302
+ delta = now_local - alarm_dt_local
303
+
304
+ seconds = float(delta.total_seconds())
305
+ if seconds < 0:
306
+ return False, 0.0, alarm_str
307
+
308
+ return seconds < window_seconds, seconds, alarm_str
309
+
310
+
311
+ def parse_timezone_value(tz_val: Any) -> datetime.tzinfo:
312
+ """Parse EZVIZ timeZone value into a tzinfo.
313
+
314
+ Supports:
315
+ - IANA names like 'Europe/Paris'
316
+ - Offsets like 'UTC+02:00', 'GMT-5', '+0530', or integers (hours/minutes/seconds)
317
+ Falls back to the local system timezone, or UTC if unavailable.
318
+ """
319
+ # IANA zone name
320
+ if isinstance(tz_val, str) and "/" in tz_val:
321
+ try:
322
+ return ZoneInfo(tz_val)
323
+ except ZoneInfoNotFoundError:
324
+ pass
325
+
326
+ # Numeric offsets
327
+ offset_minutes: int | None = None
328
+ if isinstance(tz_val, int):
329
+ if -14 <= tz_val <= 14:
330
+ offset_minutes = tz_val * 60
331
+ elif -24 * 60 <= tz_val <= 24 * 60:
332
+ offset_minutes = tz_val
333
+ elif -24 * 3600 <= tz_val <= 24 * 3600:
334
+ offset_minutes = int(tz_val / 60)
335
+ elif isinstance(tz_val, str):
336
+ s = tz_val.strip().upper().replace("UTC", "").replace("GMT", "")
337
+ m = _re.match(r"^([+-]?)(\d{1,2})(?::?(\d{2}))?$", s)
338
+ if m:
339
+ sign = -1 if m.group(1) == "-" else 1
340
+ hours = int(m.group(2))
341
+ minutes = int(m.group(3)) if m.group(3) else 0
342
+ offset_minutes = sign * (hours * 60 + minutes)
343
+
344
+ if offset_minutes is not None:
345
+ return datetime.timezone(datetime.timedelta(minutes=offset_minutes))
346
+
347
+ # Fallbacks
348
+ 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.4
4
4
  Summary: Pilot your Ezviz cameras
5
5
  Home-page: https://github.com/RenierM26/pyEzvizApi/
6
6
  Author: Renier Moorcroft
@@ -0,0 +1,21 @@
1
+ pyezvizapi/__init__.py,sha256=IDnIN_nfIISVwuy0cVBh4wspgAav6MuOJCQGajjyU3g,1881
2
+ pyezvizapi/__main__.py,sha256=9uttTuOfO22tzyomJIV8ebFJ-G-YUNDYOadZ_0AgdNA,20925
3
+ pyezvizapi/api_endpoints.py,sha256=rk6VinLVCn-B6DxnhfV79liplNpgUsipNbTEa_MRVwU,2755
4
+ pyezvizapi/camera.py,sha256=Pl5oIEdrFcv1Hz5sQI1IyyJIDCMjOjQdtExgKzmLoK8,22102
5
+ pyezvizapi/cas.py,sha256=3zHe-_a0KchCmGeAj1of-pV6oMPRUmSCIiDqBFsTK8A,6025
6
+ pyezvizapi/client.py,sha256=I20oMXw8sVVmuvgVj7eC1J6KFK_ziLM0OsJRpMuR6xs,72418
7
+ pyezvizapi/constants.py,sha256=RPyKUj0OX2VypIzcGcmmnLPDvDlCMUaZGpwADB53q4U,12585
8
+ pyezvizapi/exceptions.py,sha256=8rmxEUQdrziqMe-M1SeeRd0HtP2IDQ2xpJVj7wvOQyo,976
9
+ pyezvizapi/light_bulb.py,sha256=9wgycG3dTvBbrsxQjQnXal-GA8VXPsIN1m-CTtRh8i0,7797
10
+ pyezvizapi/models.py,sha256=NQzwTP0yEe2IWU-Vc6nAn87xulpTuo0MX2Rcf0WxifA,4176
11
+ pyezvizapi/mqtt.py,sha256=aOL-gexZgYvCCaNQ03M4vZan91d5p2Fl_qsFykn9NW4,22365
12
+ pyezvizapi/test_cam_rtsp.py,sha256=O9NHh-vcNFfnzNw8jbuhM9a_5TWfNZIMXaJP7Lmkaj4,5162
13
+ pyezvizapi/test_mqtt.py,sha256=Orn-fwZPJIE4G5KROMX0MRAkLwU6nLb9LUtXyb2ZCQs,4147
14
+ pyezvizapi/utils.py,sha256=G8gGjG0ecdN05Y0vxOHvcQMtQXgVB7nHzyvCzz66kLk,12148
15
+ pyezvizapi-1.0.2.4.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
16
+ pyezvizapi-1.0.2.4.dist-info/licenses/LICENSE.md,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
17
+ pyezvizapi-1.0.2.4.dist-info/METADATA,sha256=DnOoi3MRCjee1zRpnunI115JXlwTp3Of9Z2fBN_f1Z4,695
18
+ pyezvizapi-1.0.2.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
19
+ pyezvizapi-1.0.2.4.dist-info/entry_points.txt,sha256=_BSJ3eNb2H_AZkRdsv1s4mojqWn3N7m503ujvg1SudA,56
20
+ pyezvizapi-1.0.2.4.dist-info/top_level.txt,sha256=gMZTelIi8z7pXyTCQLLaIkxVRrDQ_lS2NEv0WgfHrHs,11
21
+ pyezvizapi-1.0.2.4.dist-info/RECORD,,
@@ -1,21 +0,0 @@
1
- pyezvizapi/__init__.py,sha256=IDnIN_nfIISVwuy0cVBh4wspgAav6MuOJCQGajjyU3g,1881
2
- pyezvizapi/__main__.py,sha256=SeV954H-AV-U1thNxRd7rWTGtSlfWyNzdrjF8gikYus,20777
3
- pyezvizapi/api_endpoints.py,sha256=rk6VinLVCn-B6DxnhfV79liplNpgUsipNbTEa_MRVwU,2755
4
- pyezvizapi/camera.py,sha256=8F8oyLnvZgl67Id6_R1m3OuhnDPNV92mTKGyLAKIFy8,28250
5
- pyezvizapi/cas.py,sha256=ISmb-eTPuacI10L87lULbQ-oDMBuygO7Tf-s9f-tvYE,5995
6
- pyezvizapi/client.py,sha256=Bp5eQbn4-pjsZicfpWy6jD5bDjQeYFw-SN1p0uzKJRY,71782
7
- pyezvizapi/constants.py,sha256=SqdJRQSRdVYQxMgJa__AgorzdWglgA4MM4H2fq3QLAE,12633
8
- pyezvizapi/exceptions.py,sha256=8rmxEUQdrziqMe-M1SeeRd0HtP2IDQ2xpJVj7wvOQyo,976
9
- pyezvizapi/light_bulb.py,sha256=9wgycG3dTvBbrsxQjQnXal-GA8VXPsIN1m-CTtRh8i0,7797
10
- pyezvizapi/models.py,sha256=NQzwTP0yEe2IWU-Vc6nAn87xulpTuo0MX2Rcf0WxifA,4176
11
- pyezvizapi/mqtt.py,sha256=aOL-gexZgYvCCaNQ03M4vZan91d5p2Fl_qsFykn9NW4,22365
12
- pyezvizapi/test_cam_rtsp.py,sha256=WGSM5EiOTl_r1mWHoMb7bXHm_BCn1P9X_669YQ38r6k,4903
13
- pyezvizapi/test_mqtt.py,sha256=Orn-fwZPJIE4G5KROMX0MRAkLwU6nLb9LUtXyb2ZCQs,4147
14
- pyezvizapi/utils.py,sha256=o342o3LI9eP8qla1vXM2rqlVbdTiLK0dAqrkyUSXpg8,5939
15
- pyezvizapi-1.0.2.2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
16
- pyezvizapi-1.0.2.2.dist-info/licenses/LICENSE.md,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
17
- pyezvizapi-1.0.2.2.dist-info/METADATA,sha256=aY8Dh0Ia8j4gTVBxgJW_JnoGFDumEfAfQTDMvNqZI2U,695
18
- pyezvizapi-1.0.2.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
19
- pyezvizapi-1.0.2.2.dist-info/entry_points.txt,sha256=_BSJ3eNb2H_AZkRdsv1s4mojqWn3N7m503ujvg1SudA,56
20
- pyezvizapi-1.0.2.2.dist-info/top_level.txt,sha256=gMZTelIi8z7pXyTCQLLaIkxVRrDQ_lS2NEv0WgfHrHs,11
21
- pyezvizapi-1.0.2.2.dist-info/RECORD,,