pyezvizapi 1.0.3.3__py3-none-any.whl → 1.0.4.3__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.
pyezvizapi/__init__.py CHANGED
@@ -39,7 +39,11 @@ from .feature import (
39
39
  day_night_sensitivity_value,
40
40
  device_icr_dss_config,
41
41
  display_mode_value,
42
+ get_algorithm_value,
43
+ has_algorithm_subtype,
42
44
  has_osd_overlay,
45
+ iter_algorithm_entries,
46
+ iter_channel_algorithm_entries,
43
47
  lens_defog_config,
44
48
  lens_defog_value,
45
49
  night_vision_config,
@@ -47,8 +51,16 @@ from .feature import (
47
51
  night_vision_luminance_value,
48
52
  night_vision_mode_value,
49
53
  night_vision_payload,
54
+ normalize_port_security,
50
55
  optionals_mapping,
56
+ port_security_config,
57
+ port_security_has_port,
58
+ port_security_port_enabled,
51
59
  resolve_channel,
60
+ supplement_light_available,
61
+ supplement_light_enabled,
62
+ supplement_light_params,
63
+ support_ext_value,
52
64
  )
53
65
  from .light_bulb import EzvizLightBulb
54
66
  from .models import EzvizDeviceRecord, build_device_records_map
@@ -91,7 +103,11 @@ __all__ = [
91
103
  "day_night_sensitivity_value",
92
104
  "device_icr_dss_config",
93
105
  "display_mode_value",
106
+ "get_algorithm_value",
107
+ "has_algorithm_subtype",
94
108
  "has_osd_overlay",
109
+ "iter_algorithm_entries",
110
+ "iter_channel_algorithm_entries",
95
111
  "lens_defog_config",
96
112
  "lens_defog_value",
97
113
  "night_vision_config",
@@ -99,6 +115,14 @@ __all__ = [
99
115
  "night_vision_luminance_value",
100
116
  "night_vision_mode_value",
101
117
  "night_vision_payload",
118
+ "normalize_port_security",
102
119
  "optionals_mapping",
120
+ "port_security_config",
121
+ "port_security_has_port",
122
+ "port_security_port_enabled",
103
123
  "resolve_channel",
124
+ "supplement_light_available",
125
+ "supplement_light_enabled",
126
+ "supplement_light_params",
127
+ "support_ext_value",
104
128
  ]
pyezvizapi/__main__.py CHANGED
@@ -260,6 +260,37 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
260
260
  "--serial", required=False, help="Optional serial to filter a single device"
261
261
  )
262
262
 
263
+ parser_unified = subparsers.add_parser(
264
+ "unifiedmsg",
265
+ help="Fetch unified message list (alarm feed) and dump URLs/metadata",
266
+ )
267
+ parser_unified.add_argument(
268
+ "--serials",
269
+ required=False,
270
+ help="Comma-separated serials to filter (default: all devices)",
271
+ )
272
+ parser_unified.add_argument(
273
+ "--limit",
274
+ type=int,
275
+ default=20,
276
+ help="Number of messages to request (max 50; default: 20)",
277
+ )
278
+ parser_unified.add_argument(
279
+ "--date",
280
+ required=False,
281
+ help="Date in YYYYMMDD format (default: today in API timezone)",
282
+ )
283
+ parser_unified.add_argument(
284
+ "--end-time",
285
+ required=False,
286
+ help="Pagination token (msgId) returned by previous call (default: latest)",
287
+ )
288
+ parser_unified.add_argument(
289
+ "--urls-only",
290
+ action="store_true",
291
+ help="Print only deviceSerial + media URLs instead of full metadata",
292
+ )
293
+
263
294
  return parser.parse_args(argv)
264
295
 
265
296
 
@@ -421,7 +452,7 @@ def _handle_devices_light(args: argparse.Namespace, client: EzvizClient) -> int:
421
452
 
422
453
  def _handle_pagelist(client: EzvizClient) -> int:
423
454
  """Output full pagelist (raw JSON) for exploration in editors like Notepad++."""
424
- data = client._get_page_list() # noqa: SLF001
455
+ data = client.get_page_list()
425
456
  _write_json(data)
426
457
  return 0
427
458
 
@@ -437,6 +468,70 @@ def _handle_device_infos(args: argparse.Namespace, client: EzvizClient) -> int:
437
468
  return 0
438
469
 
439
470
 
471
+ def _handle_unifiedmsg(args: argparse.Namespace, client: EzvizClient) -> int:
472
+ """Fetch unified message list and optionally dump media URLs."""
473
+
474
+ response = client.get_device_messages_list(
475
+ serials=args.serials,
476
+ limit=args.limit,
477
+ date=args.date,
478
+ end_time=args.end_time or "",
479
+ )
480
+ raw_messages = response.get("message")
481
+ if not isinstance(raw_messages, list):
482
+ raw_messages = response.get("messages")
483
+ if not isinstance(raw_messages, list):
484
+ raw_messages = []
485
+ messages: list[dict[str, Any]] = [msg for msg in raw_messages if isinstance(msg, dict)]
486
+
487
+ def _extract_url(message: dict[str, Any]) -> str | None:
488
+ url = message.get("pic")
489
+ if not url:
490
+ url = message.get("defaultPic") or message.get("image")
491
+ if not url:
492
+ ext = message.get("ext")
493
+ if isinstance(ext, dict):
494
+ pics = ext.get("pics")
495
+ if isinstance(pics, str) and pics:
496
+ url = pics.split(";")[0]
497
+ return url
498
+
499
+ if args.urls_only:
500
+ for item in messages:
501
+ media_url = _extract_url(item)
502
+ if not media_url:
503
+ continue
504
+ sys.stdout.write(f"{item.get('deviceSerial', 'unknown')}: {media_url}\n")
505
+ return 0
506
+
507
+ if args.json:
508
+ _write_json(messages)
509
+ return 0
510
+
511
+ rows: list[dict[str, Any]] = []
512
+ for item in messages:
513
+ ext = item.get("ext")
514
+ ext_dict = ext if isinstance(ext, dict) else None
515
+ rows.append(
516
+ {
517
+ "deviceSerial": item.get("deviceSerial"),
518
+ "time": item.get("timeStr") or item.get("time"),
519
+ "subType": item.get("subType"),
520
+ "alarmType": ext_dict.get("alarmType") if ext_dict else None,
521
+ "title": item.get("title") or item.get("detail") or (ext_dict or {}).get("alarmName"),
522
+ "url": _extract_url(item) or "",
523
+ "msgId": item.get("msgId"),
524
+ }
525
+ )
526
+
527
+ if rows:
528
+ df = pd.DataFrame(rows)
529
+ _write_df(df)
530
+ else:
531
+ sys.stdout.write("No unified messages returned.\n")
532
+ return 0
533
+
534
+
440
535
  def _handle_light(args: argparse.Namespace, client: EzvizClient) -> int:
441
536
  """Handle `light` subcommands (toggle/status)."""
442
537
  light_bulb = EzvizLightBulb(client, args.serial)
@@ -599,6 +694,8 @@ def main(argv: list[str] | None = None) -> int:
599
694
  return _handle_pagelist(client)
600
695
  if args.action == "device_infos":
601
696
  return _handle_device_infos(args, client)
697
+ if args.action == "unifiedmsg":
698
+ return _handle_unifiedmsg(args, client)
602
699
 
603
700
  except PyEzvizError as exp:
604
701
  _LOGGER.error("%s", exp)
@@ -611,7 +708,7 @@ def main(argv: list[str] | None = None) -> int:
611
708
  return 2
612
709
  finally:
613
710
  if args.save_token and args.token_file:
614
- _save_token_file(args.token_file, cast(dict[str, Any], client._token)) # noqa: SLF001
711
+ _save_token_file(args.token_file, client.export_token())
615
712
  client.close_session()
616
713
 
617
714
 
@@ -30,7 +30,8 @@ API_ENDPOINT_ALARMINFO_GET = "/v3/alarms/v2/advanced"
30
30
  API_ENDPOINT_V3_ALARMS = "/v3/alarms/"
31
31
  API_ENDPOINT_SET_LUMINANCE = "/v3/alarms/device/alarmLight/"
32
32
  API_ENDPOINT_ALARM_DEVICE_CHIME = "/v3/alarms/device/chime/"
33
- API_ENDPOINT_REMOTE_UNLOCK = "/Video/1/DoorLockMgr/RemoteUnlockReq"
33
+ API_ENDPOINT_REMOTE_UNLOCK = "/DoorLockMgr/RemoteUnlockReq"
34
+ API_ENDPOINT_REMOTE_LOCK = "/DoorLockMgr/RemoteLockReq"
34
35
  API_ENDPOINT_AUTOUPGRADE_SWITCH = "/v3/autoupgrade/v1/switch"
35
36
  API_ENDPOINT_UPGRADE_RULE = "/v3/upgraderule"
36
37
 
pyezvizapi/camera.py CHANGED
@@ -22,6 +22,11 @@ if TYPE_CHECKING:
22
22
 
23
23
  _LOGGER = logging.getLogger(__name__)
24
24
 
25
+ DEFAULT_ALARM_IMAGE_URL = (
26
+ "https://eustatics.ezvizlife.com/ovs_mall/web/img/index/EZVIZ_logo.png?ver=3007907502"
27
+ )
28
+ UNIFIEDMSG_LOOKBACK_DAYS = 7
29
+
25
30
 
26
31
  class CameraStatus(TypedDict, total=False):
27
32
  """Typed mapping for Ezviz camera status payload."""
@@ -133,25 +138,54 @@ class EzvizCamera:
133
138
  """Fetch dictionary key."""
134
139
  return fetch_nested_value(self._device, keys, default_value)
135
140
 
136
- def _alarm_list(self) -> None:
137
- """Get last alarm info for this camera's self._serial.
141
+ def _alarm_list(self, prefetched: dict[str, Any] | None = None) -> None:
142
+ """Populate last alarm info for this camera.
143
+
144
+ Args:
145
+ prefetched: Optional unified message payload provided by the caller to
146
+ avoid an extra API request. When ``None``, the camera will query the
147
+ cloud API directly with a short date lookback window.
138
148
 
139
149
  Raises:
140
150
  InvalidURL: If the API endpoint/connection is invalid.
141
151
  HTTPError: If the API returns a non-success HTTP status.
142
152
  PyEzvizError: On Ezviz API contract errors or decoding failures.
143
153
  """
144
- _alarmlist = self._client.get_alarminfo(self._serial)
145
-
146
- total = fetch_nested_value(_alarmlist, ["page", "totalResults"], 0)
147
- if total and total > 0:
148
- self._last_alarm = _alarmlist.get("alarms", [{}])[0]
154
+ if prefetched:
155
+ self._last_alarm = self._normalize_unified_message(prefetched)
149
156
  _LOGGER.debug(
150
- "Fetched last alarm for %s: %s", self._serial, self._last_alarm
157
+ "Using prefetched alarm for %s: %s", self._serial, self._last_alarm
151
158
  )
152
159
  self._motion_trigger()
153
- else:
154
- _LOGGER.debug("No alarms found for %s", self._serial)
160
+ return
161
+
162
+ response = self._client.get_device_messages_list(
163
+ serials=self._serial,
164
+ limit=1,
165
+ date="",
166
+ end_time="",
167
+ )
168
+ messages = response.get("message") or response.get("messages") or []
169
+ if not isinstance(messages, list):
170
+ messages = []
171
+ latest_message = next(
172
+ (
173
+ msg
174
+ for msg in messages
175
+ if isinstance(msg, dict)
176
+ and msg.get("deviceSerial") == self._serial
177
+ ),
178
+ None,
179
+ )
180
+ if latest_message is None:
181
+ _LOGGER.debug(
182
+ "No unified messages found for %s today",
183
+ self._serial,
184
+ )
185
+ return
186
+ self._last_alarm = self._normalize_unified_message(latest_message)
187
+ _LOGGER.debug("Fetched last alarm for %s: %s", self._serial, self._last_alarm)
188
+ self._motion_trigger()
155
189
 
156
190
  def _local_ip(self) -> str:
157
191
  """Fix empty ip value for certain cameras."""
@@ -170,6 +204,36 @@ class EzvizCamera:
170
204
 
171
205
  return "0.0.0.0"
172
206
 
207
+ def _resource_route(
208
+ self,
209
+ ) -> tuple[str, str, str | None, str | None]:
210
+ """Return resource id, local index, stream token, and optional type."""
211
+ resource_infos = self._device.get("resourceInfos") or []
212
+ info: dict[str, Any] | None = None
213
+ if isinstance(resource_infos, list):
214
+ info = next((item for item in resource_infos if isinstance(item, dict)), None)
215
+ elif isinstance(resource_infos, dict):
216
+ info = next(
217
+ (item for item in resource_infos.values() if isinstance(item, dict)), None
218
+ )
219
+
220
+ resource_id = "Video"
221
+ local_index: str = "1"
222
+ stream_token: str | None = None
223
+ lock_type: str | None = None
224
+
225
+ if info:
226
+ if isinstance(info.get("resourceId"), str):
227
+ resource_id = info["resourceId"]
228
+ local_idx_value = info.get("localIndex")
229
+ if isinstance(local_idx_value, (int, str)):
230
+ local_index = str(local_idx_value)
231
+ stream_token = info.get("streamToken")
232
+ if isinstance(info.get("type"), str):
233
+ lock_type = info["type"]
234
+
235
+ return resource_id, local_index, stream_token, lock_type
236
+
173
237
  def _motion_trigger(self) -> None:
174
238
  """Create motion sensor based on last alarm time.
175
239
 
@@ -186,6 +250,68 @@ class EzvizCamera:
186
250
  "last_alarm_time_str": last_alarm_str,
187
251
  }
188
252
 
253
+ def _normalize_unified_message(self, message: dict[str, Any]) -> dict[str, Any]:
254
+ """Normalize unified message payload to legacy alarm shape."""
255
+ ext = message.get("ext")
256
+ if not isinstance(ext, dict):
257
+ ext = {}
258
+
259
+ pics_field = ext.get("pics")
260
+ multi_pic = None
261
+ if isinstance(pics_field, str) and pics_field:
262
+ multi_pic = next(
263
+ (part for part in pics_field.split(";") if part), None
264
+ )
265
+
266
+ def _first_valid(*candidates: Any) -> str:
267
+ for candidate in candidates:
268
+ if isinstance(candidate, str) and candidate:
269
+ return candidate
270
+ return DEFAULT_ALARM_IMAGE_URL
271
+
272
+ pic_url = _first_valid(
273
+ message.get("pic"),
274
+ multi_pic,
275
+ message.get("defaultPic"),
276
+ )
277
+
278
+ alarm_name = (
279
+ message.get("title")
280
+ or message.get("detail")
281
+ or message.get("sampleName")
282
+ or "NoAlarm"
283
+ )
284
+ alarm_type = ext.get("alarmType") or message.get("subType") or "0000"
285
+
286
+ time_value: Any = message.get("time")
287
+ if isinstance(time_value, str):
288
+ try:
289
+ time_value = int(time_value)
290
+ except (TypeError, ValueError):
291
+ try:
292
+ time_value = float(time_value)
293
+ except (TypeError, ValueError):
294
+ time_value = None
295
+
296
+ time_str = message.get("timeStr") or ext.get("alarmStartTime")
297
+
298
+ return {
299
+ "alarmId": message.get("msgId"),
300
+ "deviceSerial": message.get("deviceSerial"),
301
+ "channel": message.get("channel"),
302
+ "alarmStartTime": time_value,
303
+ "alarmStartTimeStr": time_str,
304
+ "alarmTime": time_value,
305
+ "alarmTimeStr": time_str,
306
+ "picUrl": pic_url,
307
+ "picChecksum": message.get("picChecksum") or ext.get("picChecksum"),
308
+ "picCrypt": message.get("picCrypt") or ext.get("picCrypt"),
309
+ "sampleName": alarm_name,
310
+ "alarmType": alarm_type,
311
+ "msgSource": "unifiedmsg",
312
+ "ext": ext,
313
+ }
314
+
189
315
  def _get_tzinfo(self) -> datetime.tzinfo:
190
316
  """Return tzinfo from camera setting if recognizable, else local tzinfo."""
191
317
  tz_val = self.fetch_key(["STATUS", "optionals", "timeZone"])
@@ -204,10 +330,17 @@ class EzvizCamera:
204
330
  )
205
331
  return bool(sched and sched.get("enable"))
206
332
 
207
- def status(self, refresh: bool = True) -> CameraStatus:
333
+ def status(
334
+ self,
335
+ refresh: bool = True,
336
+ *,
337
+ latest_alarm: dict[str, Any] | None = None,
338
+ ) -> CameraStatus:
208
339
  """Return the status of the camera.
209
340
 
210
341
  refresh: if True, updates alarm info via network before composing status.
342
+ latest_alarm: Optional prefetched unified message payload to avoid an extra
343
+ HTTP request when ``refresh`` is True.
211
344
 
212
345
  Raises:
213
346
  InvalidURL: If the API endpoint/connection is invalid while refreshing.
@@ -215,7 +348,7 @@ class EzvizCamera:
215
348
  PyEzvizError: On Ezviz API contract errors or decoding failures.
216
349
  """
217
350
  if refresh:
218
- self._alarm_list()
351
+ self._alarm_list(prefetched=latest_alarm)
219
352
 
220
353
  name = (
221
354
  self._record.name
@@ -301,7 +434,7 @@ class EzvizCamera:
301
434
  or self._last_alarm.get("alarmStartTimeStr"),
302
435
  "last_alarm_pic": self._last_alarm.get(
303
436
  "picUrl",
304
- "https://eustatics.ezvizlife.com/ovs_mall/web/img/index/EZVIZ_logo.png?ver=3007907502",
437
+ DEFAULT_ALARM_IMAGE_URL,
305
438
  ),
306
439
  "last_alarm_type_code": self._last_alarm.get("alarmType", "0000"),
307
440
  "last_alarm_type_name": self._last_alarm.get("sampleName", "NoAlarm"),
@@ -397,7 +530,16 @@ class EzvizCamera:
397
530
  """
398
531
  _LOGGER.debug("Remote door unlock for %s", self._serial)
399
532
  user = str(getattr(self._client, "_token", {}).get("username", ""))
400
- return self._client.remote_unlock(self._serial, user, 2)
533
+ resource_id, local_index, stream_token, lock_type = self._resource_route()
534
+ return self._client.remote_unlock(
535
+ self._serial,
536
+ user,
537
+ 2,
538
+ resource_id=resource_id,
539
+ local_index=local_index,
540
+ stream_token=stream_token,
541
+ lock_type=lock_type,
542
+ )
401
543
 
402
544
  def gate_unlock(self) -> bool:
403
545
  """Unlock the gate lock.
@@ -409,7 +551,46 @@ class EzvizCamera:
409
551
  """
410
552
  _LOGGER.debug("Remote gate unlock for %s", self._serial)
411
553
  user = str(getattr(self._client, "_token", {}).get("username", ""))
412
- return self._client.remote_unlock(self._serial, user, 1)
554
+ resource_id, local_index, stream_token, lock_type = self._resource_route()
555
+ return self._client.remote_unlock(
556
+ self._serial,
557
+ user,
558
+ 1,
559
+ resource_id=resource_id,
560
+ local_index=local_index,
561
+ stream_token=stream_token,
562
+ lock_type=lock_type,
563
+ )
564
+
565
+ def door_lock(self) -> bool:
566
+ """Lock the door remotely."""
567
+ _LOGGER.debug("Remote door lock for %s", self._serial)
568
+ user = str(getattr(self._client, "_token", {}).get("username", ""))
569
+ resource_id, local_index, stream_token, lock_type = self._resource_route()
570
+ return self._client.remote_lock(
571
+ self._serial,
572
+ user,
573
+ 2,
574
+ resource_id=resource_id,
575
+ local_index=local_index,
576
+ stream_token=stream_token,
577
+ lock_type=lock_type,
578
+ )
579
+
580
+ def gate_lock(self) -> bool:
581
+ """Lock the gate remotely."""
582
+ _LOGGER.debug("Remote gate lock for %s", self._serial)
583
+ user = str(getattr(self._client, "_token", {}).get("username", ""))
584
+ resource_id, local_index, stream_token, lock_type = self._resource_route()
585
+ return self._client.remote_lock(
586
+ self._serial,
587
+ user,
588
+ 1,
589
+ resource_id=resource_id,
590
+ local_index=local_index,
591
+ stream_token=stream_token,
592
+ lock_type=lock_type,
593
+ )
413
594
 
414
595
  def alarm_notify(self, enable: bool) -> bool:
415
596
  """Enable/Disable camera notification when movement is detected.