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 +24 -0
- pyezvizapi/__main__.py +99 -2
- pyezvizapi/api_endpoints.py +2 -1
- pyezvizapi/camera.py +196 -15
- pyezvizapi/client.py +369 -35
- pyezvizapi/constants.py +25 -3
- pyezvizapi/feature.py +299 -14
- pyezvizapi/light_bulb.py +1 -1
- pyezvizapi/mqtt.py +22 -16
- pyezvizapi/test_cam_rtsp.py +8 -10
- pyezvizapi/test_mqtt.py +53 -11
- pyezvizapi/utils.py +146 -63
- pyezvizapi-1.0.4.3.dist-info/METADATA +286 -0
- pyezvizapi-1.0.4.3.dist-info/RECORD +21 -0
- pyezvizapi-1.0.3.3.dist-info/METADATA +0 -27
- pyezvizapi-1.0.3.3.dist-info/RECORD +0 -22
- pyezvizapi-1.0.3.3.dist-info/entry_points.txt +0 -2
- {pyezvizapi-1.0.3.3.dist-info → pyezvizapi-1.0.4.3.dist-info}/WHEEL +0 -0
- {pyezvizapi-1.0.3.3.dist-info → pyezvizapi-1.0.4.3.dist-info}/licenses/LICENSE +0 -0
- {pyezvizapi-1.0.3.3.dist-info → pyezvizapi-1.0.4.3.dist-info}/licenses/LICENSE.md +0 -0
- {pyezvizapi-1.0.3.3.dist-info → pyezvizapi-1.0.4.3.dist-info}/top_level.txt +0 -0
pyezvizapi/client.py
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from collections.abc import Callable, Mapping
|
|
6
|
-
|
|
5
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
6
|
+
import datetime as dt
|
|
7
7
|
import hashlib
|
|
8
8
|
import json
|
|
9
9
|
import logging
|
|
10
|
-
from typing import Any, ClassVar, TypedDict, cast
|
|
10
|
+
from typing import Any, ClassVar, NotRequired, TypedDict, cast
|
|
11
11
|
from urllib.parse import urlencode
|
|
12
12
|
from uuid import uuid4
|
|
13
13
|
|
|
@@ -70,6 +70,7 @@ from .api_endpoints import (
|
|
|
70
70
|
API_ENDPOINT_PANORAMIC_DEVICES_OPERATION,
|
|
71
71
|
API_ENDPOINT_PTZCONTROL,
|
|
72
72
|
API_ENDPOINT_REFRESH_SESSION_ID,
|
|
73
|
+
API_ENDPOINT_REMOTE_LOCK,
|
|
73
74
|
API_ENDPOINT_REMOTE_UNBIND_PROGRESS,
|
|
74
75
|
API_ENDPOINT_REMOTE_UNLOCK,
|
|
75
76
|
API_ENDPOINT_RETURN_PANORAMIC,
|
|
@@ -110,13 +111,13 @@ from .camera import EzvizCamera
|
|
|
110
111
|
from .cas import EzvizCAS
|
|
111
112
|
from .constants import (
|
|
112
113
|
DEFAULT_TIMEOUT,
|
|
114
|
+
DEFAULT_UNIFIEDMSG_STYPE,
|
|
113
115
|
FEATURE_CODE,
|
|
114
116
|
MAX_RETRIES,
|
|
115
117
|
REQUEST_HEADER,
|
|
116
118
|
DefenseModeType,
|
|
117
119
|
DeviceCatagories,
|
|
118
120
|
DeviceSwitchType,
|
|
119
|
-
MessageFilterType,
|
|
120
121
|
)
|
|
121
122
|
from .exceptions import (
|
|
122
123
|
DeviceException,
|
|
@@ -134,15 +135,18 @@ from .utils import convert_to_dict, deep_merge
|
|
|
134
135
|
|
|
135
136
|
_LOGGER = logging.getLogger(__name__)
|
|
136
137
|
|
|
138
|
+
UNIFIEDMSG_LOOKBACK_DAYS = 7
|
|
139
|
+
MAX_UNIFIEDMSG_PAGES = 6
|
|
137
140
|
|
|
138
|
-
|
|
141
|
+
|
|
142
|
+
class ClientToken(TypedDict):
|
|
139
143
|
"""Typed shape for the Ezviz client token."""
|
|
140
144
|
|
|
141
|
-
session_id: str | None
|
|
142
|
-
rf_session_id: str | None
|
|
143
|
-
username: str | None
|
|
145
|
+
session_id: NotRequired[str | None]
|
|
146
|
+
rf_session_id: NotRequired[str | None]
|
|
147
|
+
username: NotRequired[str | None]
|
|
144
148
|
api_url: str
|
|
145
|
-
service_urls: dict[str, Any]
|
|
149
|
+
service_urls: NotRequired[dict[str, Any]]
|
|
146
150
|
|
|
147
151
|
|
|
148
152
|
class MetaDict(TypedDict, total=False):
|
|
@@ -249,6 +253,7 @@ class EzvizClient:
|
|
|
249
253
|
self._cameras: dict[str, Any] = {}
|
|
250
254
|
self._light_bulbs: dict[str, Any] = {}
|
|
251
255
|
self.mqtt_client: MQTTClient | None = None
|
|
256
|
+
self._debug_request_counters: dict[str, int] = {}
|
|
252
257
|
|
|
253
258
|
def _login(self, smscode: int | None = None) -> dict[Any, Any]:
|
|
254
259
|
"""Login to Ezviz API."""
|
|
@@ -357,6 +362,15 @@ class EzvizClient:
|
|
|
357
362
|
individual endpoint behavior. Returns the Response for the caller to
|
|
358
363
|
parse and validate according to its API contract.
|
|
359
364
|
"""
|
|
365
|
+
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
366
|
+
_LOGGER.debug(
|
|
367
|
+
"HTTP %s %s params=%s data=%s json=%s",
|
|
368
|
+
method,
|
|
369
|
+
url,
|
|
370
|
+
self._summarize_payload(params),
|
|
371
|
+
self._summarize_payload(data),
|
|
372
|
+
self._summarize_payload(json_body),
|
|
373
|
+
)
|
|
360
374
|
try:
|
|
361
375
|
req = self._session.request(
|
|
362
376
|
method=method,
|
|
@@ -388,6 +402,17 @@ class EzvizClient:
|
|
|
388
402
|
)
|
|
389
403
|
raise HTTPError from err
|
|
390
404
|
else:
|
|
405
|
+
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
406
|
+
content_length = req.headers.get("Content-Length")
|
|
407
|
+
if content_length is None:
|
|
408
|
+
content_length = str(len(req.content))
|
|
409
|
+
_LOGGER.debug(
|
|
410
|
+
"HTTP %s %s -> %s (%s bytes)",
|
|
411
|
+
method,
|
|
412
|
+
url,
|
|
413
|
+
req.status_code,
|
|
414
|
+
content_length,
|
|
415
|
+
)
|
|
391
416
|
return req
|
|
392
417
|
|
|
393
418
|
@staticmethod
|
|
@@ -466,12 +491,37 @@ class EzvizClient:
|
|
|
466
491
|
return payload.get("status")
|
|
467
492
|
return None
|
|
468
493
|
|
|
494
|
+
@staticmethod
|
|
495
|
+
def _summarize_payload(payload: Any) -> str:
|
|
496
|
+
"""Return a compact description of payload content for debug logs."""
|
|
497
|
+
|
|
498
|
+
if payload is None:
|
|
499
|
+
return "-"
|
|
500
|
+
if isinstance(payload, Mapping):
|
|
501
|
+
keys = ", ".join(sorted(str(key) for key in payload))
|
|
502
|
+
return f"dict[{keys}]"
|
|
503
|
+
if isinstance(payload, (list, tuple, set)):
|
|
504
|
+
return f"{type(payload).__name__}(len={len(payload)})"
|
|
505
|
+
if isinstance(payload, (bytes, bytearray)):
|
|
506
|
+
return f"bytes(len={len(payload)})"
|
|
507
|
+
if isinstance(payload, str):
|
|
508
|
+
trimmed = payload[:32] + "…" if len(payload) > 32 else payload
|
|
509
|
+
return f"str(len={len(payload)}, preview={trimmed!r})"
|
|
510
|
+
return f"{type(payload).__name__}"
|
|
511
|
+
|
|
469
512
|
def _ensure_ok(self, payload: dict, message: str) -> None:
|
|
470
513
|
"""Raise PyEzvizError with context if response is not OK.
|
|
471
514
|
|
|
472
515
|
Accepts both API styles: new (meta.code == 200) and legacy (resultCode == 0).
|
|
473
516
|
"""
|
|
474
517
|
if not self._is_ok(payload):
|
|
518
|
+
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
519
|
+
_LOGGER.debug(
|
|
520
|
+
"API error detected (%s): code=%s payload=%s",
|
|
521
|
+
message,
|
|
522
|
+
self._response_code(payload),
|
|
523
|
+
json.dumps(payload, ensure_ascii=False),
|
|
524
|
+
)
|
|
475
525
|
raise PyEzvizError(f"{message}: Got {payload})")
|
|
476
526
|
|
|
477
527
|
def _send_prepared(
|
|
@@ -530,7 +580,17 @@ class EzvizClient:
|
|
|
530
580
|
retry_401=retry_401,
|
|
531
581
|
max_retries=max_retries,
|
|
532
582
|
)
|
|
533
|
-
|
|
583
|
+
payload = self._parse_json(resp)
|
|
584
|
+
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
585
|
+
_LOGGER.debug(
|
|
586
|
+
"JSON %s %s -> status=%s meta=%s keys=%s",
|
|
587
|
+
method,
|
|
588
|
+
path,
|
|
589
|
+
resp.status_code,
|
|
590
|
+
self._response_code(payload),
|
|
591
|
+
", ".join(sorted(payload.keys())),
|
|
592
|
+
)
|
|
593
|
+
return payload
|
|
534
594
|
|
|
535
595
|
def _retry_json(
|
|
536
596
|
self,
|
|
@@ -697,34 +757,93 @@ class EzvizClient:
|
|
|
697
757
|
def get_device_messages_list(
|
|
698
758
|
self,
|
|
699
759
|
serials: str | None = None,
|
|
700
|
-
s_type: int =
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
760
|
+
s_type: str | int | Iterable[str | int] | None = DEFAULT_UNIFIEDMSG_STYPE,
|
|
761
|
+
*,
|
|
762
|
+
limit: int = 20,
|
|
763
|
+
date: str | dt.date | dt.datetime | None = None,
|
|
764
|
+
end_time: str | int | None = "",
|
|
705
765
|
max_retries: int = 0,
|
|
706
766
|
) -> dict:
|
|
707
|
-
"""
|
|
767
|
+
r"""Return unified alarm/message list for the requested devices.
|
|
768
|
+
|
|
769
|
+
Args:
|
|
770
|
+
serials: Optional CSV string of serial numbers. ``None`` returns all.
|
|
771
|
+
s_type: Can be a string, int, iterable of either, or
|
|
772
|
+
:class:`~pyezvizapi.constants.UnifiedMessageSubtype`.
|
|
773
|
+
limit: Clamp between 1 and 50 as enforced by the public API.
|
|
774
|
+
date: Accepts ``YYYYMMDD`` string, :class:`datetime.date`, or
|
|
775
|
+
:class:`datetime.datetime`. Defaults to "today" in API format.
|
|
776
|
+
end_time: Pass the ``msgId`` (string) returned by the previous call
|
|
777
|
+
for pagination. The mobile app sends an empty string to fetch the
|
|
778
|
+
most recent message, so ``""`` is preserved on purpose here.
|
|
779
|
+
max_retries: Number of additional attempts when the backend reports
|
|
780
|
+
temporary failures. Capped by :data:`MAX_RETRIES`.
|
|
781
|
+
"""
|
|
708
782
|
if max_retries > MAX_RETRIES:
|
|
709
783
|
raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
|
|
710
784
|
|
|
711
|
-
|
|
785
|
+
def _stringify(value: Any) -> str:
|
|
786
|
+
raw = getattr(value, "value", value)
|
|
787
|
+
return str(raw)
|
|
788
|
+
|
|
789
|
+
stype_param: str | None
|
|
790
|
+
if s_type is None:
|
|
791
|
+
stype_param = DEFAULT_UNIFIEDMSG_STYPE
|
|
792
|
+
elif isinstance(s_type, str):
|
|
793
|
+
stype_param = s_type
|
|
794
|
+
elif isinstance(s_type, Iterable) and not isinstance(
|
|
795
|
+
s_type, (bytes, bytearray)
|
|
796
|
+
):
|
|
797
|
+
parts = [_stringify(item) for item in s_type if item not in (None, "")]
|
|
798
|
+
stype_param = ",".join(parts) if parts else DEFAULT_UNIFIEDMSG_STYPE
|
|
799
|
+
else:
|
|
800
|
+
stype_param = _stringify(s_type)
|
|
801
|
+
|
|
802
|
+
if date is None:
|
|
803
|
+
date_value = dt.datetime.now().strftime("%Y%m%d")
|
|
804
|
+
elif isinstance(date, (dt.date, dt.datetime)):
|
|
805
|
+
date_value = date.strftime("%Y%m%d")
|
|
806
|
+
else:
|
|
807
|
+
date_value = str(date)
|
|
808
|
+
|
|
809
|
+
try:
|
|
810
|
+
limit_value = max(1, min(int(limit), 50))
|
|
811
|
+
except (TypeError, ValueError):
|
|
812
|
+
limit_value = 20
|
|
813
|
+
|
|
814
|
+
end_time_value: str = "" if end_time is None else str(end_time)
|
|
815
|
+
|
|
816
|
+
params: dict[str, Any] = {
|
|
712
817
|
"serials": serials,
|
|
713
|
-
"stype":
|
|
714
|
-
"limit":
|
|
715
|
-
"date":
|
|
716
|
-
"endTime":
|
|
717
|
-
"tags": tags,
|
|
818
|
+
"stype": stype_param,
|
|
819
|
+
"limit": limit_value,
|
|
820
|
+
"date": date_value,
|
|
821
|
+
"endTime": end_time_value,
|
|
718
822
|
}
|
|
823
|
+
filtered_params = {k: v for k, v in params.items() if v not in (None, "")}
|
|
824
|
+
# keep empty string endTime to mimic app behavior
|
|
825
|
+
if end_time_value == "":
|
|
826
|
+
filtered_params["endTime"] = ""
|
|
719
827
|
|
|
720
828
|
json_output = self._request_json(
|
|
721
829
|
"GET",
|
|
722
830
|
API_ENDPOINT_UNIFIEDMSG_LIST_GET,
|
|
723
|
-
params=
|
|
831
|
+
params=filtered_params,
|
|
724
832
|
retry_401=True,
|
|
725
833
|
max_retries=max_retries,
|
|
726
834
|
)
|
|
727
835
|
self._ensure_ok(json_output, "Could not get unified message list")
|
|
836
|
+
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
837
|
+
counter_key = "unifiedmsg"
|
|
838
|
+
self._debug_request_counters[counter_key] = (
|
|
839
|
+
self._debug_request_counters.get(counter_key, 0) + 1
|
|
840
|
+
)
|
|
841
|
+
_LOGGER.debug(
|
|
842
|
+
"req_counter[%s]=%s params=%s",
|
|
843
|
+
counter_key,
|
|
844
|
+
self._debug_request_counters[counter_key],
|
|
845
|
+
filtered_params,
|
|
846
|
+
)
|
|
728
847
|
return json_output
|
|
729
848
|
|
|
730
849
|
def add_device(
|
|
@@ -1615,6 +1734,35 @@ class EzvizClient:
|
|
|
1615
1734
|
error_message="Could not fetch device feature value",
|
|
1616
1735
|
)
|
|
1617
1736
|
|
|
1737
|
+
def set_intelligent_fill_light(
|
|
1738
|
+
self,
|
|
1739
|
+
serial: str,
|
|
1740
|
+
*,
|
|
1741
|
+
enabled: bool,
|
|
1742
|
+
local_index: str = "1",
|
|
1743
|
+
max_retries: int = 0,
|
|
1744
|
+
) -> dict:
|
|
1745
|
+
"""Toggle the intelligent fill light mode via the IoT feature API."""
|
|
1746
|
+
|
|
1747
|
+
payload = {
|
|
1748
|
+
"value": {
|
|
1749
|
+
"enabled": bool(enabled),
|
|
1750
|
+
"supplementLightSwitchMode": "eventIntelligence"
|
|
1751
|
+
if enabled
|
|
1752
|
+
else "irLight",
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
body = self._normalize_json_payload(payload)
|
|
1756
|
+
return self.set_iot_feature(
|
|
1757
|
+
serial,
|
|
1758
|
+
resource_identifier="Video",
|
|
1759
|
+
local_index=local_index,
|
|
1760
|
+
domain_id="SupplementLightMgr",
|
|
1761
|
+
action_id="ImageSupplementLightModeSwitchParams",
|
|
1762
|
+
value=body,
|
|
1763
|
+
max_retries=max_retries,
|
|
1764
|
+
)
|
|
1765
|
+
|
|
1618
1766
|
def set_image_flip_iot(
|
|
1619
1767
|
self,
|
|
1620
1768
|
serial: str,
|
|
@@ -2042,6 +2190,26 @@ class EzvizClient:
|
|
|
2042
2190
|
records = cast(dict[str, EzvizDeviceRecord], self.get_device_records(None))
|
|
2043
2191
|
supported_categories = self.SUPPORTED_CATEGORIES
|
|
2044
2192
|
|
|
2193
|
+
def _is_supported_camera(rec: EzvizDeviceRecord) -> bool:
|
|
2194
|
+
"""Return True if record should be treated as a camera."""
|
|
2195
|
+
if rec.device_category not in supported_categories:
|
|
2196
|
+
return False
|
|
2197
|
+
if rec.device_category == DeviceCatagories.LIGHTING.value:
|
|
2198
|
+
return False
|
|
2199
|
+
return not (
|
|
2200
|
+
rec.device_category == DeviceCatagories.COMMON_DEVICE_CATEGORY.value
|
|
2201
|
+
and not ((rec.raw.get("deviceInfos") or {}).get("hik"))
|
|
2202
|
+
)
|
|
2203
|
+
|
|
2204
|
+
latest_alarms: dict[str, dict[str, Any]] = {}
|
|
2205
|
+
if refresh:
|
|
2206
|
+
camera_serials = [
|
|
2207
|
+
serial
|
|
2208
|
+
for serial, record in records.items()
|
|
2209
|
+
if _is_supported_camera(record)
|
|
2210
|
+
]
|
|
2211
|
+
latest_alarms = self._prefetch_latest_camera_alarms(camera_serials)
|
|
2212
|
+
|
|
2045
2213
|
for device, rec in records.items():
|
|
2046
2214
|
if rec.device_category in supported_categories:
|
|
2047
2215
|
# Add support for connected HikVision cameras
|
|
@@ -2062,7 +2230,7 @@ class EzvizClient:
|
|
|
2062
2230
|
KeyError,
|
|
2063
2231
|
TypeError,
|
|
2064
2232
|
ValueError,
|
|
2065
|
-
) as err:
|
|
2233
|
+
) as err:
|
|
2066
2234
|
_LOGGER.warning(
|
|
2067
2235
|
"Load_device_failed: serial=%s code=%s msg=%s",
|
|
2068
2236
|
device,
|
|
@@ -2073,13 +2241,17 @@ class EzvizClient:
|
|
|
2073
2241
|
try:
|
|
2074
2242
|
# Create camera object
|
|
2075
2243
|
cam = EzvizCamera(self, device, dict(rec.raw))
|
|
2076
|
-
self._cameras[device] = cam.status(
|
|
2244
|
+
self._cameras[device] = cam.status(
|
|
2245
|
+
refresh=refresh,
|
|
2246
|
+
latest_alarm=latest_alarms.get(device),
|
|
2247
|
+
)
|
|
2248
|
+
|
|
2077
2249
|
except (
|
|
2078
2250
|
PyEzvizError,
|
|
2079
2251
|
KeyError,
|
|
2080
2252
|
TypeError,
|
|
2081
2253
|
ValueError,
|
|
2082
|
-
) as err:
|
|
2254
|
+
) as err:
|
|
2083
2255
|
_LOGGER.warning(
|
|
2084
2256
|
"Load_device_failed: serial=%s code=%s msg=%s",
|
|
2085
2257
|
device,
|
|
@@ -2088,6 +2260,99 @@ class EzvizClient:
|
|
|
2088
2260
|
)
|
|
2089
2261
|
return {**self._cameras, **self._light_bulbs}
|
|
2090
2262
|
|
|
2263
|
+
def _prefetch_latest_camera_alarms(
|
|
2264
|
+
self, serials: Iterable[str], *, chunk_size: int = 20
|
|
2265
|
+
) -> dict[str, dict[str, Any]]:
|
|
2266
|
+
"""Attempt to fetch the most recent unified message per camera serial."""
|
|
2267
|
+
serial_list = [serial for serial in serials if serial]
|
|
2268
|
+
if not serial_list:
|
|
2269
|
+
return {}
|
|
2270
|
+
|
|
2271
|
+
latest: dict[str, dict[str, Any]] = {}
|
|
2272
|
+
|
|
2273
|
+
def _query_chunk(
|
|
2274
|
+
missing: set[str], limit: int, *, filtered: bool
|
|
2275
|
+
) -> None:
|
|
2276
|
+
"""Populate latest alarms for a given chunk, retrying a few times."""
|
|
2277
|
+
attempts = 0
|
|
2278
|
+
while missing and attempts < MAX_UNIFIEDMSG_PAGES:
|
|
2279
|
+
attempts += 1
|
|
2280
|
+
serial_param = None if not filtered else ",".join(sorted(missing))
|
|
2281
|
+
try:
|
|
2282
|
+
response = self.get_device_messages_list(
|
|
2283
|
+
serials=serial_param,
|
|
2284
|
+
limit=limit,
|
|
2285
|
+
date="",
|
|
2286
|
+
end_time="",
|
|
2287
|
+
max_retries=1,
|
|
2288
|
+
)
|
|
2289
|
+
except PyEzvizError as err:
|
|
2290
|
+
_LOGGER.debug(
|
|
2291
|
+
"alarm_prefetch_failed: serials=%s error=%r",
|
|
2292
|
+
serial_param or "",
|
|
2293
|
+
err,
|
|
2294
|
+
)
|
|
2295
|
+
return
|
|
2296
|
+
|
|
2297
|
+
items = response.get("message") or response.get("messages") or []
|
|
2298
|
+
if not isinstance(items, list) or not items:
|
|
2299
|
+
return
|
|
2300
|
+
|
|
2301
|
+
matched = 0
|
|
2302
|
+
for item in items:
|
|
2303
|
+
serial = item.get("deviceSerial")
|
|
2304
|
+
if (
|
|
2305
|
+
isinstance(serial, str)
|
|
2306
|
+
and serial in missing
|
|
2307
|
+
and serial not in latest
|
|
2308
|
+
):
|
|
2309
|
+
latest[serial] = item
|
|
2310
|
+
missing.discard(serial)
|
|
2311
|
+
matched += 1
|
|
2312
|
+
|
|
2313
|
+
# If this filtered call returned fewer entries than we still need,
|
|
2314
|
+
# assume remaining serials truly have no data and stop retrying.
|
|
2315
|
+
if filtered and matched < len(missing):
|
|
2316
|
+
return
|
|
2317
|
+
|
|
2318
|
+
if not response.get("hasNext"):
|
|
2319
|
+
return
|
|
2320
|
+
|
|
2321
|
+
remaining_serials = set(serial_list)
|
|
2322
|
+
|
|
2323
|
+
# First, try a global fetch without serial filtering to capture the freshest alarms
|
|
2324
|
+
before = set(remaining_serials)
|
|
2325
|
+
_query_chunk(remaining_serials, limit=50, filtered=False)
|
|
2326
|
+
satisfied = before - remaining_serials
|
|
2327
|
+
if satisfied:
|
|
2328
|
+
remaining_serials.difference_update(satisfied)
|
|
2329
|
+
|
|
2330
|
+
for start_idx in range(0, len(serial_list), chunk_size):
|
|
2331
|
+
chunk = [
|
|
2332
|
+
serial
|
|
2333
|
+
for serial in serial_list[start_idx : start_idx + chunk_size]
|
|
2334
|
+
if serial
|
|
2335
|
+
]
|
|
2336
|
+
if not chunk:
|
|
2337
|
+
continue
|
|
2338
|
+
remaining = {serial for serial in chunk if serial in remaining_serials}
|
|
2339
|
+
if not remaining:
|
|
2340
|
+
continue
|
|
2341
|
+
chunk_key = ",".join(chunk)
|
|
2342
|
+
limit = min(50, max(len(chunk), 20))
|
|
2343
|
+
before_chunk = set(remaining)
|
|
2344
|
+
_query_chunk(remaining, limit, filtered=True)
|
|
2345
|
+
satisfied_chunk = before_chunk - remaining
|
|
2346
|
+
if satisfied_chunk:
|
|
2347
|
+
remaining_serials.difference_update(satisfied_chunk)
|
|
2348
|
+
if remaining:
|
|
2349
|
+
_LOGGER.debug(
|
|
2350
|
+
"alarm_prefetch_incomplete: serials=%s missing=%s",
|
|
2351
|
+
chunk_key,
|
|
2352
|
+
",".join(sorted(remaining)),
|
|
2353
|
+
)
|
|
2354
|
+
return latest
|
|
2355
|
+
|
|
2091
2356
|
def load_cameras(self, refresh: bool = True) -> dict[Any, Any]:
|
|
2092
2357
|
"""Load and return all camera status mappings.
|
|
2093
2358
|
|
|
@@ -2546,13 +2811,30 @@ class EzvizClient:
|
|
|
2546
2811
|
self._ensure_ok(json_output, "Could not get door lock users")
|
|
2547
2812
|
return json_output
|
|
2548
2813
|
|
|
2549
|
-
def remote_unlock(
|
|
2814
|
+
def remote_unlock(
|
|
2815
|
+
self,
|
|
2816
|
+
serial: str,
|
|
2817
|
+
user_id: str,
|
|
2818
|
+
lock_no: int,
|
|
2819
|
+
*,
|
|
2820
|
+
resource_id: str | None = None,
|
|
2821
|
+
local_index: str | int | None = None,
|
|
2822
|
+
stream_token: str | None = None,
|
|
2823
|
+
lock_type: str | None = None,
|
|
2824
|
+
) -> bool:
|
|
2550
2825
|
"""Sends a remote command to unlock a specific lock.
|
|
2551
2826
|
|
|
2552
2827
|
Args:
|
|
2553
2828
|
serial (str): The camera serial.
|
|
2554
2829
|
user_id (str): The user id.
|
|
2555
2830
|
lock_no (int): The lock number.
|
|
2831
|
+
resource_id (str, optional): Resource identifier reported by the device
|
|
2832
|
+
(for example ``Video`` or ``DoorLock``). Defaults to ``"Video"``.
|
|
2833
|
+
local_index (str | int, optional): Local channel index for the lock.
|
|
2834
|
+
Defaults to ``"1"``.
|
|
2835
|
+
stream_token (str, optional): Stream token associated with the lock if
|
|
2836
|
+
provided by the API. Defaults to empty string when omitted.
|
|
2837
|
+
lock_type (str, optional): Optional lock type hint used by some devices.
|
|
2556
2838
|
|
|
2557
2839
|
Raises:
|
|
2558
2840
|
PyEzvizError: If max retries are exceeded or if the response indicates failure.
|
|
@@ -2562,17 +2844,20 @@ class EzvizClient:
|
|
|
2562
2844
|
bool: True if the operation was successful.
|
|
2563
2845
|
|
|
2564
2846
|
"""
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2847
|
+
route_resource = resource_id or "Video"
|
|
2848
|
+
route_index = str(local_index if local_index is not None else 1)
|
|
2849
|
+
un_lock_info: dict[str, Any] = {
|
|
2850
|
+
"bindCode": f"{FEATURE_CODE}{user_id}",
|
|
2851
|
+
"lockNo": lock_no,
|
|
2852
|
+
"streamToken": stream_token or "",
|
|
2853
|
+
"userName": user_id,
|
|
2572
2854
|
}
|
|
2855
|
+
if lock_type:
|
|
2856
|
+
un_lock_info["type"] = lock_type
|
|
2857
|
+
payload = {"unLockInfo": un_lock_info}
|
|
2573
2858
|
json_result = self._request_json(
|
|
2574
2859
|
"PUT",
|
|
2575
|
-
f"{API_ENDPOINT_IOT_ACTION}{serial}{API_ENDPOINT_REMOTE_UNLOCK}",
|
|
2860
|
+
f"{API_ENDPOINT_IOT_ACTION}{serial}/{route_resource}/{route_index}{API_ENDPOINT_REMOTE_UNLOCK}",
|
|
2576
2861
|
json_body=payload,
|
|
2577
2862
|
retry_401=True,
|
|
2578
2863
|
max_retries=0,
|
|
@@ -2585,6 +2870,45 @@ class EzvizClient:
|
|
|
2585
2870
|
)
|
|
2586
2871
|
return True
|
|
2587
2872
|
|
|
2873
|
+
def remote_lock(
|
|
2874
|
+
self,
|
|
2875
|
+
serial: str,
|
|
2876
|
+
user_id: str,
|
|
2877
|
+
lock_no: int,
|
|
2878
|
+
*,
|
|
2879
|
+
resource_id: str | None = None,
|
|
2880
|
+
local_index: str | int | None = None,
|
|
2881
|
+
stream_token: str | None = None,
|
|
2882
|
+
lock_type: str | None = None,
|
|
2883
|
+
) -> bool:
|
|
2884
|
+
"""Send a remote lock command to a specific lock."""
|
|
2885
|
+
|
|
2886
|
+
route_resource = resource_id or "Video"
|
|
2887
|
+
route_index = str(local_index if local_index is not None else 1)
|
|
2888
|
+
un_lock_info: dict[str, Any] = {
|
|
2889
|
+
"bindCode": f"{FEATURE_CODE}{user_id}",
|
|
2890
|
+
"lockNo": lock_no,
|
|
2891
|
+
"streamToken": stream_token or "",
|
|
2892
|
+
"userName": user_id,
|
|
2893
|
+
}
|
|
2894
|
+
if lock_type:
|
|
2895
|
+
un_lock_info["type"] = lock_type
|
|
2896
|
+
payload = {"unLockInfo": un_lock_info}
|
|
2897
|
+
json_result = self._request_json(
|
|
2898
|
+
"PUT",
|
|
2899
|
+
f"{API_ENDPOINT_IOT_ACTION}{serial}/{route_resource}/{route_index}{API_ENDPOINT_REMOTE_LOCK}",
|
|
2900
|
+
json_body=payload,
|
|
2901
|
+
retry_401=True,
|
|
2902
|
+
max_retries=0,
|
|
2903
|
+
)
|
|
2904
|
+
_LOGGER.debug(
|
|
2905
|
+
"http_debug: serial=%s code=%s msg=%s",
|
|
2906
|
+
serial,
|
|
2907
|
+
self._response_code(json_result),
|
|
2908
|
+
"remote_lock",
|
|
2909
|
+
)
|
|
2910
|
+
return True
|
|
2911
|
+
|
|
2588
2912
|
def get_remote_unbind_progress(
|
|
2589
2913
|
self,
|
|
2590
2914
|
serial: str,
|
|
@@ -4368,6 +4692,16 @@ class EzvizClient:
|
|
|
4368
4692
|
json_key=None,
|
|
4369
4693
|
)
|
|
4370
4694
|
|
|
4695
|
+
def get_page_list(self) -> Any:
|
|
4696
|
+
"""Return the full pagelist payload without filtering."""
|
|
4697
|
+
|
|
4698
|
+
return self._get_page_list()
|
|
4699
|
+
|
|
4700
|
+
def export_token(self) -> dict[str, Any]:
|
|
4701
|
+
"""Return a shallow copy of the current authentication token."""
|
|
4702
|
+
|
|
4703
|
+
return dict(self._token)
|
|
4704
|
+
|
|
4371
4705
|
def get_device(self) -> Any:
|
|
4372
4706
|
"""Get ezviz devices filter."""
|
|
4373
4707
|
return self._api_get_pagelist(page_filter="CLOUD", json_key="deviceInfos")
|
pyezvizapi/constants.py
CHANGED
|
@@ -6,13 +6,25 @@ the Ezviz API to descriptive names.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from enum import Enum, unique
|
|
9
|
+
from hashlib import md5
|
|
10
|
+
import uuid
|
|
9
11
|
|
|
10
|
-
from .utils import generate_unique_code
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
def _generate_unique_code() -> str:
|
|
14
|
+
"""Generate a deterministic unique code for this host."""
|
|
15
|
+
|
|
16
|
+
mac_int = uuid.getnode()
|
|
17
|
+
mac_str = ":".join(f"{(mac_int >> i) & 0xFF:02x}" for i in range(40, -1, -8))
|
|
18
|
+
return md5(mac_str.encode("utf-8")).hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
FEATURE_CODE = _generate_unique_code()
|
|
13
22
|
XOR_KEY = b"\x0c\x0eJ^X\x15@Rr"
|
|
14
23
|
DEFAULT_TIMEOUT = 25
|
|
15
24
|
MAX_RETRIES = 3
|
|
25
|
+
# Unified message API default subtype that returns all alarm categories.
|
|
26
|
+
DEFAULT_UNIFIEDMSG_STYPE = "92"
|
|
27
|
+
HIK_ENCRYPTION_HEADER = b"hikencodepicture"
|
|
16
28
|
REQUEST_HEADER = {
|
|
17
29
|
"featureCode": FEATURE_CODE,
|
|
18
30
|
"clientType": "3",
|
|
@@ -34,7 +46,7 @@ APP_SECRET = "17454517-cc1c-42b3-a845-99b4a15dd3e6"
|
|
|
34
46
|
|
|
35
47
|
@unique
|
|
36
48
|
class MessageFilterType(Enum):
|
|
37
|
-
"""
|
|
49
|
+
"""Fine-grained message filters used by the unified list API."""
|
|
38
50
|
|
|
39
51
|
FILTER_TYPE_MOTION = 2402
|
|
40
52
|
FILTER_TYPE_PERSON = 2403
|
|
@@ -44,6 +56,16 @@ class MessageFilterType(Enum):
|
|
|
44
56
|
FILTER_TYPE_SYSTEM_MESSAGE = 2101
|
|
45
57
|
|
|
46
58
|
|
|
59
|
+
@unique
|
|
60
|
+
class UnifiedMessageSubtype(str, Enum):
|
|
61
|
+
"""High-level subtype bundles supported by the Ezviz mobile app."""
|
|
62
|
+
|
|
63
|
+
# Equivalent to the "All alarm" chip in the official app UI.
|
|
64
|
+
ALL_ALARMS = "92"
|
|
65
|
+
# Same comma-separated bundle returned by msgDefaultSubtype() inside the app.
|
|
66
|
+
DEFAULT_APP_SUBTYPE = "9904,2701"
|
|
67
|
+
|
|
68
|
+
|
|
47
69
|
@unique
|
|
48
70
|
class DeviceSwitchType(Enum):
|
|
49
71
|
"""Device switch name and number."""
|