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/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
- from datetime import datetime
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
- class ClientToken(TypedDict, total=False):
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
- return self._parse_json(resp)
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 = MessageFilterType.FILTER_TYPE_ALL_ALARM.value,
701
- limit: int | None = 20, # 50 is the max even if you set it higher
702
- date: str = datetime.today().strftime("%Y%m%d"),
703
- end_time: str | None = None,
704
- tags: str = "ALL",
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
- """Get data from Unified message list API."""
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
- params: dict[str, int | str | None] = {
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": s_type,
714
- "limit": limit,
715
- "date": date,
716
- "endTime": end_time,
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=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: # pragma: no cover - defensive
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(refresh=refresh)
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: # pragma: no cover - defensive
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(self, serial: str, user_id: str, lock_no: int) -> bool:
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
- payload = {
2566
- "unLockInfo": {
2567
- "bindCode": f"{FEATURE_CODE}{user_id}",
2568
- "lockNo": lock_no,
2569
- "streamToken": "",
2570
- "userName": user_id,
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
- FEATURE_CODE = generate_unique_code()
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
- """Message filter types for unified list."""
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."""