pyezvizapi 1.0.2.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
@@ -34,6 +34,34 @@ from .exceptions import (
34
34
  InvalidURL,
35
35
  PyEzvizError,
36
36
  )
37
+ from .feature import (
38
+ day_night_mode_value,
39
+ day_night_sensitivity_value,
40
+ device_icr_dss_config,
41
+ display_mode_value,
42
+ get_algorithm_value,
43
+ has_algorithm_subtype,
44
+ has_osd_overlay,
45
+ iter_algorithm_entries,
46
+ iter_channel_algorithm_entries,
47
+ lens_defog_config,
48
+ lens_defog_value,
49
+ night_vision_config,
50
+ night_vision_duration_value,
51
+ night_vision_luminance_value,
52
+ night_vision_mode_value,
53
+ night_vision_payload,
54
+ normalize_port_security,
55
+ optionals_mapping,
56
+ port_security_config,
57
+ port_security_has_port,
58
+ port_security_port_enabled,
59
+ resolve_channel,
60
+ supplement_light_available,
61
+ supplement_light_enabled,
62
+ supplement_light_params,
63
+ support_ext_value,
64
+ )
37
65
  from .light_bulb import EzvizLightBulb
38
66
  from .models import EzvizDeviceRecord, build_device_records_map
39
67
  from .mqtt import EzvizToken, MQTTClient, MqttData, ServiceUrls
@@ -71,4 +99,30 @@ __all__ = [
71
99
  "SupportExt",
72
100
  "TestRTSPAuth",
73
101
  "build_device_records_map",
102
+ "day_night_mode_value",
103
+ "day_night_sensitivity_value",
104
+ "device_icr_dss_config",
105
+ "display_mode_value",
106
+ "get_algorithm_value",
107
+ "has_algorithm_subtype",
108
+ "has_osd_overlay",
109
+ "iter_algorithm_entries",
110
+ "iter_channel_algorithm_entries",
111
+ "lens_defog_config",
112
+ "lens_defog_value",
113
+ "night_vision_config",
114
+ "night_vision_duration_value",
115
+ "night_vision_luminance_value",
116
+ "night_vision_mode_value",
117
+ "night_vision_payload",
118
+ "normalize_port_security",
119
+ "optionals_mapping",
120
+ "port_security_config",
121
+ "port_security_has_port",
122
+ "port_security_port_enabled",
123
+ "resolve_channel",
124
+ "supplement_light_available",
125
+ "supplement_light_enabled",
126
+ "supplement_light_params",
127
+ "support_ext_value",
74
128
  ]
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,12 +253,44 @@ 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"
251
261
  )
252
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
+
253
294
  return parser.parse_args(argv)
254
295
 
255
296
 
@@ -411,18 +452,86 @@ def _handle_devices_light(args: argparse.Namespace, client: EzvizClient) -> int:
411
452
 
412
453
  def _handle_pagelist(client: EzvizClient) -> int:
413
454
  """Output full pagelist (raw JSON) for exploration in editors like Notepad++."""
414
- data = client._get_page_list() # noqa: SLF001
455
+ data = client.get_page_list()
415
456
  _write_json(data)
416
457
  return 0
417
458
 
418
459
 
419
460
  def _handle_device_infos(args: argparse.Namespace, client: EzvizClient) -> int:
420
461
  """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()
462
+ data = (
463
+ client.get_device_infos(args.serial)
464
+ if args.serial
465
+ else client.get_device_infos()
466
+ )
422
467
  _write_json(data)
423
468
  return 0
424
469
 
425
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
+
426
535
  def _handle_light(args: argparse.Namespace, client: EzvizClient) -> int:
427
536
  """Handle `light` subcommands (toggle/status)."""
428
537
  light_bulb = EzvizLightBulb(client, args.serial)
@@ -535,7 +644,10 @@ def _load_token_file(path: str | None) -> dict[str, Any] | None:
535
644
  return None
536
645
  try:
537
646
  return cast(dict[str, Any], json.loads(p.read_text(encoding="utf-8")))
538
- except (OSError, json.JSONDecodeError): # pragma: no cover - tolerate malformed file
647
+ except (
648
+ OSError,
649
+ json.JSONDecodeError,
650
+ ): # pragma: no cover - tolerate malformed file
539
651
  _LOGGER.warning("Failed to read token file: %s", p)
540
652
  return None
541
653
 
@@ -582,6 +694,8 @@ def main(argv: list[str] | None = None) -> int:
582
694
  return _handle_pagelist(client)
583
695
  if args.action == "device_infos":
584
696
  return _handle_device_infos(args, client)
697
+ if args.action == "unifiedmsg":
698
+ return _handle_unifiedmsg(args, client)
585
699
 
586
700
  except PyEzvizError as exp:
587
701
  _LOGGER.error("%s", exp)
@@ -594,7 +708,7 @@ def main(argv: list[str] | None = None) -> int:
594
708
  return 2
595
709
  finally:
596
710
  if args.save_token and args.token_file:
597
- _save_token_file(args.token_file, cast(dict[str, Any], client._token)) # noqa: SLF001
711
+ _save_token_file(args.token_file, client.export_token())
598
712
  client.close_session()
599
713
 
600
714
 
@@ -22,13 +22,22 @@ API_ENDPOINT_UNIFIEDMSG_LIST_GET = "/v3/unifiedmsg/list"
22
22
  API_ENDPOINT_IOT_FEATURE = "/v3/iot-feature/feature/"
23
23
  API_ENDPOINT_IOT_ACTION = "/v3/iot-feature/action/"
24
24
  API_ENDPOINT_CALLING_NOTIFY = "/v3/calling/"
25
+ API_ENDPOINT_SPECIAL_BIZS_VOICES = "/v3/specialBizs/voices"
26
+ API_ENDPOINT_SPECIAL_BIZS_A1S = "/v3/specialBizs/A1S/"
27
+ API_ENDPOINT_SPECIAL_BIZS_V1_BATTERY = "/v3/specialBizs/v1/batteryDevices/"
25
28
 
26
29
  API_ENDPOINT_ALARMINFO_GET = "/v3/alarms/v2/advanced"
27
30
  API_ENDPOINT_V3_ALARMS = "/v3/alarms/"
28
31
  API_ENDPOINT_SET_LUMINANCE = "/v3/alarms/device/alarmLight/"
29
- API_ENDPOINT_REMOTE_UNLOCK = "/Video/1/DoorLockMgr/RemoteUnlockReq"
32
+ API_ENDPOINT_ALARM_DEVICE_CHIME = "/v3/alarms/device/chime/"
33
+ API_ENDPOINT_REMOTE_UNLOCK = "/DoorLockMgr/RemoteUnlockReq"
34
+ API_ENDPOINT_REMOTE_LOCK = "/DoorLockMgr/RemoteLockReq"
35
+ API_ENDPOINT_AUTOUPGRADE_SWITCH = "/v3/autoupgrade/v1/switch"
36
+ API_ENDPOINT_UPGRADE_RULE = "/v3/upgraderule"
30
37
 
31
38
  API_ENDPOINT_DEVCONFIG_BY_KEY = "/v3/devconfig/v1/keyValue/"
39
+ API_ENDPOINT_DEVCONFIG_OP = "/v3/devconfig/op"
40
+ API_ENDPOINT_DEVCONFIG_MOTOR = "/v3/devconfig/motor"
32
41
  API_ENDPOINT_CAM_AUTH_CODE = "/v3/devconfig/authcode/query/"
33
42
 
34
43
  API_ENDPOINT_INTELLIGENT_APP = "/v3/intelligent-app/load/"
@@ -37,12 +46,27 @@ API_ENDPOINT_DEVICE_BASICS = "/v3/basics/v1/devices/"
37
46
 
38
47
  API_ENDPOINT_DETECTION_SENSIBILITY = "/api/device/configAlgorithm"
39
48
  API_ENDPOINT_DETECTION_SENSIBILITY_GET = "/api/device/queryAlgorithmConfig"
49
+ API_ENDPOINT_SENSITIVITY = "/v3/devconfig/v1/sensitivity/"
40
50
  API_ENDPOINT_SET_DEFENCE_SCHEDULE = "/api/device/defence/plan2"
51
+ API_ENDPOINT_DEVICE_SWITCH_STATUS_LEGACY = "/api/device/switchStatus"
41
52
  API_ENDPOINT_CAM_ENCRYPTKEY = "/api/device/query/encryptkey"
42
53
  API_ENDPOINT_OFFLINE_NOTIFY = "/api/device/notify/switch"
43
54
  API_ENDPOINT_CANCEL_ALARM = "/api/device/cancelAlarm"
44
55
  API_ENDPOINT_DEVICE_SYS_OPERATION = "/api/device/v2/sysOper/"
45
56
  API_ENDPOINT_DEVICE_STORAGE_STATUS = "/api/device/queryStorageStatus"
57
+ API_ENDPOINT_DEVICE_UPDATE_NAME = "/api/device/updateName"
58
+ API_ENDPOINT_DEVCONFIG_SECURITY_ACTIVATE = "/v3/devconfig/security/activate/"
59
+ API_ENDPOINT_DEVCONFIG_SECURITY_CHALLENGE = "/v3/devconfig/security/challenge/"
60
+ API_ENDPOINT_DEVICES_LOC = "/v3/devices/loc"
61
+ API_ENDPOINT_IOT_VIRTUAL_BIND = "/v3/iot-virtual-device/bindDevice"
62
+ API_ENDPOINT_DEVCONFIG_BASE = "/v3/devconfig"
63
+ API_ENDPOINT_USERDEVICES_TOKEN = "/v3/userdevices/token"
64
+ API_ENDPOINT_DEVICE_ACCESSORY_LINK = "/v3/devices/accessory/"
65
+ API_ENDPOINT_DOORLOCK_USERS = "/v3/doorlocks/"
66
+ API_ENDPOINT_IOT_FEATURE_PRODUCT_VOICE_CONFIG = "/v3/iot-feature/product/voice/config"
67
+ API_ENDPOINT_USERS_LBS_SUB_DOMAIN = "/v3/users/lbs/sub/domain"
68
+ API_ENDPOINT_SCD_APP_DEVICE_ADD = "/v3/scd/app/device/add"
69
+ API_ENDPOINT_REMOTE_UNBIND_PROGRESS = "/v3/remote/unbind/device/applicant/"
46
70
  API_ENDPOINT_CREATE_PANORAMIC = "/api/panoramic/devices/pics/collect"
47
71
  API_ENDPOINT_RETURN_PANORAMIC = "/api/panoramic/devices/pics"
48
72
 
@@ -53,9 +77,37 @@ API_ENDPOINT_SWITCH_OTHER = "/switch"
53
77
  API_ENDPOINT_PTZCONTROL = "/ptzControl"
54
78
  API_ENDPOINT_ALARM_SOUND = "/alarm/sound"
55
79
  API_ENDPOINT_SWITCH_SOUND_ALARM = "/sendAlarm"
80
+ API_ENDPOINT_ALARM_SET_CHANNEL_WHISTLE = "/alarm/setChannelWhistle"
81
+ API_ENDPOINT_ALARM_SET_DEVICE_WHISTLE = "/alarm/setDeviceWhistle"
82
+ API_ENDPOINT_ALARM_STOP_WHISTLE = "/alarm/stopWhistle"
83
+ API_ENDPOINT_ALARM_GET_WHISTLE_STATUS_BY_CHANNEL = "/alarm/getWhistleStatusByChannel"
84
+ API_ENDPOINT_ALARM_GET_WHISTLE_STATUS_BY_DEVICE = "/alarm/getWhistleStatusByDevice"
56
85
  API_ENDPOINT_DO_NOT_DISTURB = "/nodisturb"
57
86
  API_ENDPOINT_VIDEO_ENCRYPT = "encryptedInfo/risk"
58
87
  API_ENDPOINT_CHANGE_DEFENCE_STATUS = "/changeDefenceStatusReq"
88
+ API_ENDPOINT_DEVICES_P2P_INFO = "/v3/devices/v2/p2p"
89
+ API_ENDPOINT_USERDEVICES_P2P_INFO = "/v3/userdevices/v1/devices/p2p"
90
+ API_ENDPOINT_USERDEVICES_KMS = "/v3/userdevices/v1/devices/kms"
91
+ API_ENDPOINT_USERDEVICES_STATUS = "/v3/userdevices/v1/devices/status"
92
+ API_ENDPOINT_USERDEVICES_V2 = "/v3/userdevices/v2/devices"
93
+ API_ENDPOINT_USERDEVICES_SEARCH = "/v3/userdevices/v2/devices"
94
+ API_ENDPOINT_STREAMING_RECORDS = "/v3/streaming/records"
95
+ API_ENDPOINT_DEVICES_ENCRYPTKEY_BATCH = "/v3/devices/encryptkey/query/batch/risk"
96
+ API_ENDPOINT_MANAGED_DEVICE_BASE = "/v3/userdevices/v1/managed/"
97
+ API_ENDPOINT_SDCARD_BLACK_LEVEL = "/v3/devconfig/v1/sdcard/"
98
+ API_ENDPOINT_TIME_PLAN_INFOS = "/v3/devconfig/v2/plan"
99
+ API_ENDPOINT_FEEDBACK = "/v3/feedback/v2"
100
+ API_ENDPOINT_SHARE_ACCEPT = "/v3/share/accept"
101
+ API_ENDPOINT_SHARE_QUIT = "/v3/share/quit"
102
+ API_ENDPOINT_DEVICE_EMAIL_ALERT = "/v3/unifiedmsg/notify/switch/128"
103
+ API_ENDPOINT_DEVICES_AUTHENTICATE = "/v3/devices/authenticate/"
104
+ API_ENDPOINT_DEVICES_SET_SWITCH_ENABLE = "/setSwitchEnableReq"
105
+ API_ENDPOINT_SMARTHOME_OUTLET_LOG = (
106
+ "/v3/smarthome/outlet/log/switch/from/{from}/to/{to}"
107
+ )
108
+ API_ENDPOINT_DEVICES_ASSOCIATION_LINKED_IPC = (
109
+ "/v3/devices/association/linked/ipcList/byA1"
110
+ )
59
111
 
60
112
  # MQTT
61
113
  API_ENDPOINT_REGISTER_MQTT = "/v1/getClientId"
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.
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"