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 +54 -0
- pyezvizapi/__main__.py +124 -10
- pyezvizapi/api_endpoints.py +53 -1
- pyezvizapi/camera.py +196 -15
- pyezvizapi/cas.py +4 -2
- pyezvizapi/client.py +3693 -953
- pyezvizapi/constants.py +33 -4
- pyezvizapi/feature.py +536 -0
- pyezvizapi/light_bulb.py +1 -1
- pyezvizapi/mqtt.py +22 -16
- pyezvizapi/test_cam_rtsp.py +43 -21
- pyezvizapi/test_mqtt.py +53 -11
- pyezvizapi/utils.py +182 -71
- pyezvizapi-1.0.4.3.dist-info/METADATA +286 -0
- pyezvizapi-1.0.4.3.dist-info/RECORD +21 -0
- pyezvizapi-1.0.2.3.dist-info/METADATA +0 -27
- pyezvizapi-1.0.2.3.dist-info/RECORD +0 -21
- pyezvizapi-1.0.2.3.dist-info/entry_points.txt +0 -2
- {pyezvizapi-1.0.2.3.dist-info → pyezvizapi-1.0.4.3.dist-info}/WHEEL +0 -0
- {pyezvizapi-1.0.2.3.dist-info → pyezvizapi-1.0.4.3.dist-info}/licenses/LICENSE +0 -0
- {pyezvizapi-1.0.2.3.dist-info → pyezvizapi-1.0.4.3.dist-info}/licenses/LICENSE.md +0 -0
- {pyezvizapi-1.0.2.3.dist-info → pyezvizapi-1.0.4.3.dist-info}/top_level.txt +0 -0
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(
|
|
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(
|
|
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",
|
|
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=[
|
|
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",
|
|
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.
|
|
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 =
|
|
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 (
|
|
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,
|
|
711
|
+
_save_token_file(args.token_file, client.export_token())
|
|
598
712
|
client.close_session()
|
|
599
713
|
|
|
600
714
|
|
pyezvizapi/api_endpoints.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
"
|
|
157
|
+
"Using prefetched alarm for %s: %s", self._serial, self._last_alarm
|
|
151
158
|
)
|
|
152
159
|
self._motion_trigger()
|
|
153
|
-
|
|
154
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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"
|