pyezvizapi 1.0.1.7__py3-none-any.whl → 1.0.1.8__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.

Potentially problematic release.


This version of pyezvizapi might be problematic. Click here for more details.

pyezvizapi/constants.py CHANGED
@@ -1,4 +1,9 @@
1
- """Device switch types relationship."""
1
+ """Constants and enums used by the Ezviz Cloud API wrapper.
2
+
3
+ Includes default timeouts, request headers used to emulate the mobile
4
+ client, and a large collection of enums that map integers/strings from
5
+ the Ezviz API to descriptive names.
6
+ """
2
7
 
3
8
  from enum import Enum, unique
4
9
 
pyezvizapi/exceptions.py CHANGED
@@ -1,33 +1,33 @@
1
- """PyEzviz Exceptions."""
1
+ """Custom exceptions raised by the Ezviz Cloud API wrapper."""
2
2
 
3
3
 
4
4
  class PyEzvizError(Exception):
5
- """Ezviz api exception."""
5
+ """Base exception for all Ezviz API related errors."""
6
6
 
7
7
 
8
8
  class InvalidURL(PyEzvizError):
9
- """Invalid url exception."""
9
+ """Raised when a request fails due to an invalid URL or proxy settings."""
10
10
 
11
11
 
12
12
  class HTTPError(PyEzvizError):
13
- """Invalid host exception."""
13
+ """Raised when a non-success HTTP status code is returned by the API."""
14
14
 
15
15
 
16
16
  class InvalidHost(PyEzvizError):
17
- """Invalid host exception."""
17
+ """Raised when a hostname/IP is invalid or a TCP connection fails."""
18
18
 
19
19
 
20
20
  class AuthTestResultFailed(PyEzvizError):
21
- """Authentication failed."""
21
+ """Raised by RTSP auth test helpers if credentials are invalid."""
22
22
 
23
23
 
24
24
  class EzvizAuthTokenExpired(PyEzvizError):
25
- """Authentication failed because token is invalid or expired."""
25
+ """Raised when a stored session token is no longer valid (expired/revoked)."""
26
26
 
27
27
 
28
28
  class EzvizAuthVerificationCode(PyEzvizError):
29
- """Authentication failed because MFA verification code is required."""
29
+ """Raised when a login or action requires an MFA (verification) code."""
30
30
 
31
31
 
32
32
  class DeviceException(PyEzvizError):
33
- """The device network is abnormal, please check the device network or try again."""
33
+ """Raised when the physical device reports network or operational issues."""
pyezvizapi/light_bulb.py CHANGED
@@ -1,4 +1,9 @@
1
- """Ezviz camera api."""
1
+ """Ezviz light bulb API.
2
+
3
+ Light-bulb specific helpers to read device status and control
4
+ features exposed via the Ezviz cloud API (on/off, brightness,
5
+ color temperature, etc.).
6
+ """
2
7
 
3
8
  from __future__ import annotations
4
9
 
@@ -11,53 +16,84 @@ from .utils import fetch_nested_value
11
16
 
12
17
  if TYPE_CHECKING:
13
18
  from .client import EzvizClient
19
+ from .models import EzvizDeviceRecord
14
20
 
15
21
 
16
22
  class EzvizLightBulb:
17
- """Initialize Ezviz Light Bulb object."""
23
+ """Representation of an Ezviz light bulb.
24
+
25
+ Provides a thin, typed wrapper over the pagelist/device payload
26
+ for a light bulb, plus convenience methods to toggle and set
27
+ brightness. This class mirrors the camera interface where
28
+ possible to keep integration code simple.
29
+ """
18
30
 
19
31
  def __init__(
20
- self, client: EzvizClient, serial: str, device_obj: dict | None = None
32
+ self,
33
+ client: EzvizClient,
34
+ serial: str,
35
+ device_obj: EzvizDeviceRecord | dict | None = None,
21
36
  ) -> None:
22
- """Initialize the light bulb object."""
37
+ """Initialize the light bulb object.
38
+
39
+ Raises:
40
+ InvalidURL: If the API endpoint/connection is invalid when fetching device info.
41
+ HTTPError: If the API returns a non-success HTTP status while fetching device info.
42
+ PyEzvizError: On Ezviz API contract errors or decoding failures.
43
+ """
23
44
  self._client = client
24
45
  self._serial = serial
25
- self._device = (
26
- device_obj if device_obj else self._client.get_device_infos(self._serial)
27
- )
46
+ if device_obj is None:
47
+ self._device = self._client.get_device_infos(self._serial)
48
+ elif isinstance(device_obj, EzvizDeviceRecord):
49
+ self._device = dict(device_obj.raw)
50
+ else:
51
+ self._device = device_obj
28
52
  self._feature_json = self.get_feature_json()
29
- self._switch: dict[int, bool] = {
30
- switch["type"]: switch["enable"] for switch in self._device["SWITCH"]
31
- }
53
+ switches = self._device.get("SWITCH") or []
54
+ self._switch: dict[int, bool] = {}
55
+ if isinstance(switches, list):
56
+ for switch in switches:
57
+ if not isinstance(switch, dict):
58
+ continue
59
+ t = switch.get("type")
60
+ en = switch.get("enable")
61
+ if isinstance(t, int) and isinstance(en, (bool, int)):
62
+ self._switch[t] = bool(en)
32
63
  if DeviceSwitchType.ALARM_LIGHT.value not in self._switch:
33
64
  # trying to have same interface as the camera's light
34
65
  self._switch[DeviceSwitchType.ALARM_LIGHT.value] = self.get_feature_item(
35
66
  "light_switch"
36
67
  )["dataValue"]
37
68
 
38
- def fetch_key(self, keys: list, default_value: Any = None) -> Any:
39
- """Fetch dictionary key."""
69
+ def fetch_key(self, keys: list[Any], default_value: Any = None) -> Any:
70
+ """Fetch a nested key from the device payload.
71
+
72
+ Uses the same semantics as the camera helper.
73
+ """
40
74
  return fetch_nested_value(self._device, keys, default_value)
41
75
 
42
- def _local_ip(self) -> Any:
43
- """Fix empty ip value for certain cameras."""
44
- if (
45
- self.fetch_key(["WIFI", "address"])
46
- and self._device["WIFI"]["address"] != "0.0.0.0"
47
- ):
48
- return self._device["WIFI"]["address"]
76
+ def _local_ip(self) -> str:
77
+ """Best-effort local IP address for devices that report 0.0.0.0."""
78
+ wifi = self._device.get("WIFI") or {}
79
+ addr = wifi.get("address")
80
+ if isinstance(addr, str) and addr != "0.0.0.0":
81
+ return addr
49
82
 
50
83
  # Seems to return none or 0.0.0.0 on some.
51
- if (
52
- self.fetch_key(["CONNECTION", "localIp"])
53
- and self._device["CONNECTION"]["localIp"] != "0.0.0.0"
54
- ):
55
- return self._device["CONNECTION"]["localIp"]
84
+ conn = self._device.get("CONNECTION") or {}
85
+ local_ip = conn.get("localIp")
86
+ if isinstance(local_ip, str) and local_ip != "0.0.0.0":
87
+ return local_ip
56
88
 
57
89
  return "0.0.0.0"
58
90
 
59
91
  def get_feature_json(self) -> Any:
60
- """Parse the FEATURE json."""
92
+ """Parse the FEATURE JSON string into a Python structure.
93
+
94
+ Raises:
95
+ PyEzvizError: If the FEATURE JSON cannot be decoded.
96
+ """
61
97
  try:
62
98
  json_output = json.loads(self._device["FEATURE"]["featureJson"])
63
99
 
@@ -67,7 +103,7 @@ class EzvizLightBulb:
67
103
  return json_output
68
104
 
69
105
  def get_feature_item(self, key: str, default_value: Any = None) -> Any:
70
- """Get items from FEATURE."""
106
+ """Return a feature item by key from the parsed FEATURE structure."""
71
107
  items = self._feature_json["featureItemDtos"]
72
108
 
73
109
  for item in items:
@@ -77,11 +113,11 @@ class EzvizLightBulb:
77
113
  return default_value if default_value else {"dataValue": ""}
78
114
 
79
115
  def get_product_id(self) -> Any:
80
- """Get product id."""
116
+ """Return the product ID from the FEATURE metadata."""
81
117
  return self._feature_json["productId"]
82
118
 
83
119
  def status(self) -> dict[Any, Any]:
84
- """Return the status of the light bulb."""
120
+ """Return a status dictionary mirroring the camera status shape where possible."""
85
121
  return {
86
122
  "serial": self._serial,
87
123
  "name": self.fetch_key(["deviceInfos", "name"]),
@@ -118,7 +154,16 @@ class EzvizLightBulb:
118
154
  }
119
155
 
120
156
  def _write_state(self, state: bool | None = None) -> bool:
121
- """Set the light bulb state."""
157
+ """Set the light bulb state.
158
+
159
+ If ``state`` is None, the current state will be toggled.
160
+ Returns True on success.
161
+
162
+ Raises:
163
+ PyEzvizError: On API failures.
164
+ InvalidURL: If the API endpoint/connection is invalid.
165
+ HTTPError: If the API returns a non-success HTTP status.
166
+ """
122
167
  item = self.get_feature_item("light_switch")
123
168
 
124
169
  return self._client.set_device_feature_by_key(
@@ -131,15 +176,19 @@ class EzvizLightBulb:
131
176
  def set_brightness(self, value: int) -> bool:
132
177
  """Set the light bulb brightness.
133
178
 
134
- The value must be in range 1-100.
179
+ The value must be in range 1100. Returns True on success.
135
180
 
181
+ Raises:
182
+ PyEzvizError: On API failures.
183
+ InvalidURL: If the API endpoint/connection is invalid.
184
+ HTTPError: If the API returns a non-success HTTP status.
136
185
  """
137
186
  return self._client.set_device_feature_by_key(
138
187
  self._serial, self.get_product_id(), value, "brightness"
139
188
  )
140
189
 
141
190
  def toggle_switch(self) -> bool:
142
- """Toggle on/off light bulb."""
191
+ """Toggle the light bulb on/off."""
143
192
  return self._write_state()
144
193
 
145
194
  def power_on(self) -> bool:
pyezvizapi/models.py ADDED
@@ -0,0 +1,103 @@
1
+ """Lightweight models for Ezviz API payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class EzvizDeviceRecord:
12
+ """A light, ergonomic view over Ezviz get_device_infos() output.
13
+
14
+ Captures commonly used fields with a stable API while preserving
15
+ the full raw mapping for advanced/one-off access.
16
+ """
17
+
18
+ serial: str
19
+ name: str | None
20
+ device_category: str | None
21
+ device_sub_category: str | None
22
+ version: str | None
23
+ status: int | None
24
+
25
+ # Popular sections (pass-through subsets)
26
+ support_ext: Mapping[str, Any] | None = None
27
+ connection: Mapping[str, Any] | None = None
28
+ wifi: Mapping[str, Any] | None = None
29
+ qos: Mapping[str, Any] | None = None
30
+ vtm: Mapping[str, Any] | None = None
31
+ cloud: Mapping[str, Any] | None = None
32
+ p2p: Any | None = None
33
+ time_plan: Any | None = None
34
+ optionals: Mapping[str, Any] | None = None
35
+
36
+ # Switches collapsed to a simple type->enabled map for convenience
37
+ switches: Mapping[int, bool] = field(default_factory=dict)
38
+
39
+ # Full unmodified mapping for anything not yet modeled
40
+ raw: Mapping[str, Any] = field(default_factory=dict)
41
+
42
+ @classmethod
43
+ def from_api(cls, serial: str, data: Mapping[str, Any]) -> EzvizDeviceRecord:
44
+ """Build EzvizDeviceRecord from raw pagelist mapping.
45
+
46
+ Tolerates missing or partially shaped keys.
47
+ """
48
+ device_infos = data.get("deviceInfos", {}) or {}
49
+ status = (data.get("STATUS", {}) or {})
50
+ optionals = status.get("optionals") if isinstance(status, dict) else None
51
+
52
+ # Collapse SWITCH list[{type, enable}] to {type: enable}
53
+ switches_list = data.get("SWITCH") or []
54
+ switches: dict[int, bool] = {}
55
+ for item in switches_list if isinstance(switches_list, list) else []:
56
+ t = item.get("type")
57
+ en = item.get("enable")
58
+ if isinstance(t, int) and isinstance(en, (bool, int)):
59
+ switches[t] = bool(en)
60
+
61
+ return cls(
62
+ serial=serial,
63
+ name=device_infos.get("name"),
64
+ device_category=device_infos.get("deviceCategory") or device_infos.get("device_category"),
65
+ device_sub_category=device_infos.get("deviceSubCategory") or device_infos.get("device_sub_category"),
66
+ version=device_infos.get("version"),
67
+ status=device_infos.get("status") or status.get("globalStatus") if isinstance(status, dict) else None,
68
+ support_ext=device_infos.get("supportExt"),
69
+ connection=data.get("CONNECTION"),
70
+ wifi=data.get("WIFI"),
71
+ qos=data.get("QOS"),
72
+ vtm=next(iter((data.get("VTM") or {}).values()), None),
73
+ cloud=next(iter((data.get("CLOUD") or {}).values()), None),
74
+ p2p=data.get("P2P"),
75
+ time_plan=data.get("TIME_PLAN"),
76
+ optionals=optionals if isinstance(optionals, dict) else None,
77
+ switches=switches,
78
+ raw=data,
79
+ )
80
+
81
+
82
+ def build_device_records_map(devices: Mapping[str, Any]) -> dict[str, EzvizDeviceRecord]:
83
+ """Convert get_device_infos() mapping → {serial: EzvizDeviceRecord}.
84
+
85
+ Keeps behavior robust to partial/missing keys.
86
+ """
87
+ out: dict[str, EzvizDeviceRecord] = {}
88
+ for serial, payload in (devices or {}).items():
89
+ try:
90
+ out[serial] = EzvizDeviceRecord.from_api(serial, payload)
91
+ except (TypeError, KeyError, ValueError):
92
+ # Do not crash on unexpected shapes; fall back to raw wrapper
93
+ out[serial] = EzvizDeviceRecord(
94
+ serial=serial,
95
+ name=(payload.get("deviceInfos") or {}).get("name"),
96
+ device_category=(payload.get("deviceInfos") or {}).get("deviceCategory"),
97
+ device_sub_category=(payload.get("deviceInfos") or {}).get("deviceSubCategory"),
98
+ version=(payload.get("deviceInfos") or {}).get("version"),
99
+ status=(payload.get("deviceInfos") or {}).get("status"),
100
+ raw=payload,
101
+ switches={},
102
+ )
103
+ return out
pyezvizapi/mqtt.py CHANGED
@@ -97,10 +97,10 @@ EXT_FIELD_NAMES: Final[tuple[str, ...]] = (
97
97
  "status_flag",
98
98
  "file_id",
99
99
  "is_encrypted",
100
- "encrypted_pwd_hash",
100
+ "picChecksum",
101
101
  "unknown_flag",
102
102
  "unused5",
103
- "alarm_log_id",
103
+ "msgId",
104
104
  "image",
105
105
  "device_name",
106
106
  "unused6",
@@ -221,6 +221,8 @@ class MQTTClient:
221
221
 
222
222
  Raises:
223
223
  PyEzvizError: If required Ezviz credentials are missing or registration/start fails.
224
+ InvalidURL: If a push API endpoint is invalid or unreachable.
225
+ HTTPError: If a push API request returns a non-success status.
224
226
  """
225
227
  self._register_ezviz_push()
226
228
  self._start_ezviz_push()
@@ -258,8 +260,9 @@ class MQTTClient:
258
260
  self, client: mqtt.Client, userdata: Any, mid: int, granted_qos: tuple[int, ...]
259
261
  ) -> None:
260
262
  """Handle subscription acknowledgement from the broker."""
261
- _LOGGER.info("Subscribed: mid=%s qos=%s", mid, granted_qos)
262
- _LOGGER.info("Subscribed to EZVIZ MQTT topic: %s", self._topic)
263
+ _LOGGER.debug(
264
+ "MQTT subscribed: topic=%s mid=%s qos=%s", self._topic, mid, granted_qos
265
+ )
263
266
 
264
267
  def _on_connect(
265
268
  self, client: mqtt.Client, userdata: Any, flags: dict, rc: int
@@ -277,14 +280,17 @@ class MQTTClient:
277
280
  session_present = (
278
281
  flags.get("session present") if isinstance(flags, dict) else None
279
282
  )
280
- _LOGGER.info(
281
- "Connected to EZVIZ broker rc=%s session_present=%s", rc, session_present
282
- )
283
+ _LOGGER.debug("MQTT connected: rc=%s session_present=%s", rc, session_present)
283
284
  if rc == 0 and not session_present:
284
285
  client.subscribe(self._topic, qos=2)
285
286
  if rc != 0:
286
287
  # Let paho handle reconnects (reconnect_delay_set configured)
287
- _LOGGER.error("MQTT connection failed, return code: %s", rc)
288
+ _LOGGER.error(
289
+ "MQTT connect failed: serial=%s code=%s msg=%s",
290
+ "unknown",
291
+ rc,
292
+ "connect_failed",
293
+ )
288
294
 
289
295
  def _on_disconnect(self, client: mqtt.Client, userdata: Any, rc: int) -> None:
290
296
  """Called when the MQTT client disconnects from the broker.
@@ -296,7 +302,12 @@ class MQTTClient:
296
302
  userdata (Any): The user data passed to the client (not used).
297
303
  rc (int): Disconnect result code. 0 indicates a clean disconnect.
298
304
  """
299
- _LOGGER.warning("Disconnected from EZVIZ MQTT broker (rc=%s)", rc)
305
+ _LOGGER.debug(
306
+ "MQTT disconnected: serial=%s code=%s msg=%s",
307
+ "unknown",
308
+ rc,
309
+ "disconnected",
310
+ )
300
311
 
301
312
  def _on_message(
302
313
  self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage
@@ -314,15 +325,30 @@ class MQTTClient:
314
325
  try:
315
326
  decoded = self.decode_mqtt_message(msg.payload)
316
327
  except PyEzvizError as err:
317
- _LOGGER.warning("Failed to decode MQTT message: %s", err)
328
+ _LOGGER.warning("MQTT_decode_error: msg=%s", str(err))
318
329
  return
319
330
 
320
- device_serial = decoded.get("ext", {}).get("device_serial")
331
+ ext: dict[str, Any] = (
332
+ decoded.get("ext", {}) if isinstance(decoded.get("ext"), dict) else {}
333
+ )
334
+ device_serial = ext.get("device_serial")
335
+ alert_code = ext.get("alert_type_code")
336
+ msg_id = ext.get("msgId")
337
+
321
338
  if device_serial:
322
339
  self._cache_message(device_serial, decoded)
323
- _LOGGER.debug("Stored message for device_serial %s", device_serial)
340
+ _LOGGER.debug(
341
+ "mqtt_msg: serial=%s alert_code=%s msg_id=%s",
342
+ device_serial,
343
+ alert_code,
344
+ msg_id,
345
+ )
324
346
  else:
325
- _LOGGER.warning("Received message with no device_serial: %s", decoded)
347
+ _LOGGER.warning(
348
+ "MQTT_message_missing_serial: alert_code=%s msg_id=%s",
349
+ alert_code,
350
+ msg_id,
351
+ )
326
352
 
327
353
  if self._on_message_callback:
328
354
  try:
@@ -438,7 +464,9 @@ class MQTTClient:
438
464
  )
439
465
 
440
466
  self._mqtt_data["ticket"] = json_output["ticket"]
441
- _LOGGER.info("EZVIZ MQTT ticket acquired")
467
+ _LOGGER.debug(
468
+ "MQTT ticket acquired: client_id=%s", self._mqtt_data["mqtt_clientid"]
469
+ )
442
470
 
443
471
  def _stop_ezviz_push(self) -> None:
444
472
  """Stop push notifications for this client via the Ezviz API.