pyezvizapi 1.0.1.6__tar.gz → 1.0.1.7__tar.gz

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.

Files changed (27) hide show
  1. {pyezvizapi-1.0.1.6/pyezvizapi.egg-info → pyezvizapi-1.0.1.7}/PKG-INFO +1 -1
  2. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/pyezvizapi/__init__.py +4 -1
  3. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/pyezvizapi/client.py +16 -0
  4. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/pyezvizapi/constants.py +3 -1
  5. pyezvizapi-1.0.1.7/pyezvizapi/mqtt.py +588 -0
  6. pyezvizapi-1.0.1.7/pyezvizapi/test_mqtt.py +64 -0
  7. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/pyezvizapi/utils.py +28 -1
  8. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7/pyezvizapi.egg-info}/PKG-INFO +1 -1
  9. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/pyezvizapi.egg-info/SOURCES.txt +1 -0
  10. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/setup.py +1 -1
  11. pyezvizapi-1.0.1.6/pyezvizapi/mqtt.py +0 -259
  12. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/LICENSE +0 -0
  13. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/LICENSE.md +0 -0
  14. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/MANIFEST.in +0 -0
  15. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/README.md +0 -0
  16. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/pyezvizapi/__main__.py +0 -0
  17. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/pyezvizapi/api_endpoints.py +0 -0
  18. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/pyezvizapi/camera.py +0 -0
  19. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/pyezvizapi/cas.py +0 -0
  20. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/pyezvizapi/exceptions.py +0 -0
  21. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/pyezvizapi/light_bulb.py +0 -0
  22. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/pyezvizapi/test_cam_rtsp.py +0 -0
  23. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/pyezvizapi.egg-info/dependency_links.txt +0 -0
  24. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/pyezvizapi.egg-info/entry_points.txt +0 -0
  25. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/pyezvizapi.egg-info/requires.txt +0 -0
  26. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/pyezvizapi.egg-info/top_level.txt +0 -0
  27. {pyezvizapi-1.0.1.6 → pyezvizapi-1.0.1.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyezvizapi
3
- Version: 1.0.1.6
3
+ Version: 1.0.1.7
4
4
  Summary: Pilot your Ezviz cameras
5
5
  Home-page: https://github.com/RenierM26/pyEzvizApi/
6
6
  Author: Renier Moorcroft
@@ -28,7 +28,7 @@ from .exceptions import (
28
28
  PyEzvizError,
29
29
  )
30
30
  from .light_bulb import EzvizLightBulb
31
- from .mqtt import MQTTClient
31
+ from .mqtt import EzvizToken, MQTTClient, MqttData, ServiceUrls
32
32
  from .test_cam_rtsp import TestRTSPAuth
33
33
 
34
34
  __all__ = [
@@ -47,14 +47,17 @@ __all__ = [
47
47
  "EzvizCamera",
48
48
  "EzvizClient",
49
49
  "EzvizLightBulb",
50
+ "EzvizToken",
50
51
  "HTTPError",
51
52
  "IntelligentDetectionSmartApp",
52
53
  "InvalidHost",
53
54
  "InvalidURL",
54
55
  "MQTTClient",
55
56
  "MessageFilterType",
57
+ "MqttData",
56
58
  "NightVisionMode",
57
59
  "PyEzvizError",
60
+ "ServiceUrls",
58
61
  "SoundMode",
59
62
  "SupportExt",
60
63
  "TestRTSPAuth",
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Callable
5
6
  from datetime import datetime
6
7
  import hashlib
7
8
  import json
@@ -79,6 +80,7 @@ from .exceptions import (
79
80
  PyEzvizError,
80
81
  )
81
82
  from .light_bulb import EzvizLightBulb
83
+ from .mqtt import MQTTClient
82
84
  from .utils import convert_to_dict, deep_merge
83
85
 
84
86
  _LOGGER = logging.getLogger(__name__)
@@ -112,6 +114,7 @@ class EzvizClient:
112
114
  self._timeout = timeout
113
115
  self._cameras: dict[str, Any] = {}
114
116
  self._light_bulbs: dict[str, Any] = {}
117
+ self.mqtt_client: MQTTClient | None = None
115
118
 
116
119
  def _login(self, smscode: int | None = None) -> dict[Any, Any]:
117
120
  """Login to Ezviz API."""
@@ -2488,6 +2491,19 @@ class EzvizClient:
2488
2491
 
2489
2492
  return True
2490
2493
 
2494
+ def get_mqtt_client(
2495
+ self, on_message_callback: Callable[[dict[str, Any]], None] | None = None
2496
+ ) -> MQTTClient:
2497
+ """Return a configured MQTTClient using this client's session."""
2498
+ if self.mqtt_client is None:
2499
+ self.mqtt_client = MQTTClient(
2500
+ token=self._token,
2501
+ session=self._session,
2502
+ timeout=self._timeout,
2503
+ on_message_callback=on_message_callback,
2504
+ )
2505
+ return self.mqtt_client
2506
+
2491
2507
  def _get_page_list(self) -> Any:
2492
2508
  """Get ezviz device info broken down in sections."""
2493
2509
  return self._api_get_pagelist(
@@ -2,7 +2,9 @@
2
2
 
3
3
  from enum import Enum, unique
4
4
 
5
- FEATURE_CODE = "1fc28fa018178a1cd1c091b13b2f9f02"
5
+ from .utils import generate_unique_code
6
+
7
+ FEATURE_CODE = generate_unique_code()
6
8
  XOR_KEY = b"\x0c\x0eJ^X\x15@Rr"
7
9
  DEFAULT_TIMEOUT = 25
8
10
  MAX_RETRIES = 3
@@ -0,0 +1,588 @@
1
+ """Ezviz cloud MQTT client for push messages.
2
+
3
+ Synchronous MQTT client tailored for EZVIZ push notifications as used by
4
+ `pyezvizapi` and Home Assistant integrations. Handles the EZVIZ registration
5
+ flow, starts/stops push, maintains a long-lived MQTT connection, and decodes
6
+ incoming payloads into a structured form.
7
+
8
+ This module is intentionally synchronous (uses `requests` and
9
+ `paho-mqtt`'s background network thread via `loop_start()`), which keeps
10
+ integration code simple. If you later migrate to an async HA integration,
11
+ wrap the blocking calls with `hass.async_add_executor_job`.
12
+
13
+ Example:
14
+ >>> client = MQTTClient(token)
15
+ >>> client.connect()
16
+ >>> # ... handle callbacks or read client.messages_by_device ...
17
+ >>> client.stop()
18
+
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import base64
24
+ from collections import OrderedDict
25
+ from collections.abc import Callable
26
+ from contextlib import suppress
27
+ import json
28
+ import logging
29
+ from typing import Any, Final, TypedDict
30
+
31
+ import paho.mqtt.client as mqtt
32
+ import requests
33
+
34
+ from .api_endpoints import (
35
+ API_ENDPOINT_REGISTER_MQTT,
36
+ API_ENDPOINT_START_MQTT,
37
+ API_ENDPOINT_STOP_MQTT,
38
+ )
39
+ from .constants import APP_SECRET, DEFAULT_TIMEOUT, FEATURE_CODE, MQTT_APP_KEY
40
+ from .exceptions import HTTPError, InvalidURL, PyEzvizError
41
+
42
+ _LOGGER = logging.getLogger(__name__)
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Typed structures
47
+ # ---------------------------------------------------------------------------
48
+
49
+
50
+ class ServiceUrls(TypedDict):
51
+ """Service URLs present in the EZVIZ auth token.
52
+
53
+ Attributes:
54
+ pushAddr: Hostname of the EZVIZ push/MQTT entry point.
55
+ """
56
+
57
+ pushAddr: str
58
+
59
+
60
+ class EzvizToken(TypedDict):
61
+ """Minimal shape of the EZVIZ token required for MQTT.
62
+
63
+ Attributes:
64
+ username: Internal EZVIZ username.
65
+ session_id: Current session id.
66
+ service_urls: Nested object containing at least ``pushAddr``.
67
+ """
68
+
69
+ username: str
70
+ session_id: str
71
+ service_urls: ServiceUrls
72
+
73
+
74
+ class MqttData(TypedDict):
75
+ """Typed dictionary for EZVIZ MQTT connection data."""
76
+
77
+ mqtt_clientid: str | None
78
+ ticket: str | None
79
+ push_url: str
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Payload decoding helpers
84
+ # ---------------------------------------------------------------------------
85
+
86
+ # Field names in the comma‑separated ``ext`` payload from EZVIZ.
87
+ EXT_FIELD_NAMES: Final[tuple[str, ...]] = (
88
+ "channel_type",
89
+ "time",
90
+ "device_serial",
91
+ "channel_no",
92
+ "alert_type_code",
93
+ "unused1",
94
+ "unused2",
95
+ "unused3",
96
+ "unused4",
97
+ "status_flag",
98
+ "file_id",
99
+ "is_encrypted",
100
+ "encrypted_pwd_hash",
101
+ "unknown_flag",
102
+ "unused5",
103
+ "alarm_log_id",
104
+ "image",
105
+ "device_name",
106
+ "unused6",
107
+ "sequence_number",
108
+ )
109
+
110
+ # Fields that should be converted to ``int`` if present.
111
+ EXT_INT_FIELDS: Final[frozenset[str]] = frozenset(
112
+ {
113
+ "channel_type",
114
+ "channel_no",
115
+ "alert_type_code",
116
+ "status_flag",
117
+ "is_encrypted",
118
+ "sequence_number",
119
+ }
120
+ )
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Client
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ class MQTTClient:
129
+ """MQTT client for Ezviz push notifications.
130
+
131
+ Handles the Ezviz-specific registration and connection process,
132
+ maintains a persistent MQTT connection, and processes incoming messages.
133
+
134
+ Messages are stored per device_serial in `messages_by_device`, and an optional
135
+ callback can be provided to handle messages as they arrive.
136
+
137
+ Typical usage::
138
+
139
+ client = MQTTClient(token=auth_token)
140
+ client.connect(clean_session=True)
141
+
142
+ # Access last message for a device
143
+ last_msg = client.messages_by_device.get(device_serial)
144
+
145
+ # Stop the client when done
146
+ client.stop()
147
+ """
148
+
149
+ def __init__(
150
+ self,
151
+ token: EzvizToken | dict,
152
+ session: requests.Session,
153
+ timeout: int = DEFAULT_TIMEOUT,
154
+ on_message_callback: Callable[[dict[str, Any]], None] | None = None,
155
+ *,
156
+ max_messages: int = 1000,
157
+ ) -> None:
158
+ """Initialize the Ezviz MQTT client.
159
+
160
+ This client handles registration with the Ezviz push service, maintains
161
+ a persistent MQTT connection, and decodes incoming push messages.
162
+
163
+ Args:
164
+ token (dict): Authentication token dictionary returned by EzvizClient.login().
165
+ Must include:
166
+ - 'username': Ezviz account username (The account aliase or generated one.)
167
+ - 'session_id': session token for API access
168
+ - 'service_urls': dictionary containing at least 'pushAddr'
169
+ timeout (int, optional): HTTP request timeout in seconds. Defaults to DEFAULT_TIMEOUT.
170
+ session (requests.Session): Pre-configured requests session for HTTP calls.
171
+ on_message_callback (Callable[[dict[str, Any]], None], optional): Optional callback function
172
+ that will be called for each decoded MQTT message. The callback receives
173
+ a dictionary with the message data. Defaults to None.
174
+ max_messages:
175
+ Maximum number of device entries kept in :attr:`messages_by_device`.
176
+ Oldest entries are evicted when the limit is exceeded. Defaults to ``1000``.
177
+
178
+ Raises:
179
+ PyEzvizError: If the provided token is missing required fields.
180
+ """
181
+ if not token or not token.get("username"):
182
+ raise PyEzvizError(
183
+ "Ezviz internal username is required. Ensure EzvizClient.login() was called first."
184
+ )
185
+
186
+ # Requests session (synchronous)
187
+ self._session = session
188
+
189
+ self._token: EzvizToken | dict = token
190
+ self._timeout: int = timeout
191
+ self._topic: str = f"{MQTT_APP_KEY}/#"
192
+ self._on_message_callback = on_message_callback
193
+ self._max_messages: int = max_messages
194
+
195
+ self._mqtt_data: MqttData = {
196
+ "mqtt_clientid": None,
197
+ "ticket": None,
198
+ "push_url": token["service_urls"]["pushAddr"],
199
+ }
200
+
201
+ self.mqtt_client: mqtt.Client | None = None
202
+ # Keep last payload per device, bounded by ``max_messages``
203
+ self.messages_by_device: OrderedDict[str, dict[str, Any]] = OrderedDict()
204
+
205
+ # ------------------------------------------------------------------
206
+ # Public API
207
+ # ------------------------------------------------------------------
208
+
209
+ def connect(self, *, clean_session: bool = False, keepalive: int = 60) -> None:
210
+ """Connect to the Ezviz MQTT broker and start receiving push messages.
211
+
212
+ This method performs the following steps:
213
+ 1. Registers the client with Ezviz push service.
214
+ 2. Starts push notifications for this client.
215
+ 3. Configures and connects the underlying MQTT client.
216
+ 4. Starts the MQTT network loop in a background thread.
217
+
218
+ Keyword Args:
219
+ clean_session (bool, optional): Whether to start a clean MQTT session. Defaults to False.
220
+ keepalive (int, optional): Keep-alive interval in seconds for the MQTT connection. Defaults to 60.
221
+
222
+ Raises:
223
+ PyEzvizError: If required Ezviz credentials are missing or registration/start fails.
224
+ """
225
+ self._register_ezviz_push()
226
+ self._start_ezviz_push()
227
+ self._configure_mqtt(clean_session=clean_session)
228
+ assert self.mqtt_client is not None
229
+ self.mqtt_client.connect(self._mqtt_data["push_url"], 1882, keepalive)
230
+ self.mqtt_client.loop_start()
231
+
232
+ def stop(self) -> None:
233
+ """Stop the MQTT client and push notifications.
234
+
235
+ This method stops the MQTT network loop, disconnects from the broker,
236
+ and signals the Ezviz API to stop push notifications.
237
+
238
+ This method is idempotent and can be called multiple times safely.
239
+
240
+ Raises:
241
+ PyEzvizError: If stopping the push service fails.
242
+ """
243
+ if self.mqtt_client:
244
+ try:
245
+ # Stop background thread and disconnect
246
+ self.mqtt_client.loop_stop()
247
+ self.mqtt_client.disconnect()
248
+ except Exception as err: # noqa: BLE001
249
+ _LOGGER.debug("MQTT disconnect failed: %s", err)
250
+ # Always attempt to stop push on server side
251
+ self._stop_ezviz_push()
252
+
253
+ # ------------------------------------------------------------------
254
+ # MQTT callbacks
255
+ # ------------------------------------------------------------------
256
+
257
+ def _on_subscribe(
258
+ self, client: mqtt.Client, userdata: Any, mid: int, granted_qos: tuple[int, ...]
259
+ ) -> None:
260
+ """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
+
264
+ def _on_connect(
265
+ self, client: mqtt.Client, userdata: Any, flags: dict, rc: int
266
+ ) -> None:
267
+ """Handle successful or failed MQTT connection attempts.
268
+
269
+ Subscribes to the topic if this is a new session and logs connection status.
270
+
271
+ Args:
272
+ client (mqtt.Client): The MQTT client instance.
273
+ userdata (Any): The user data passed to the client (not used).
274
+ flags (dict): MQTT flags dictionary, includes 'session present'.
275
+ rc (int): MQTT connection result code. 0 indicates success.
276
+ """
277
+ session_present = (
278
+ flags.get("session present") if isinstance(flags, dict) else None
279
+ )
280
+ _LOGGER.info(
281
+ "Connected to EZVIZ broker rc=%s session_present=%s", rc, session_present
282
+ )
283
+ if rc == 0 and not session_present:
284
+ client.subscribe(self._topic, qos=2)
285
+ if rc != 0:
286
+ # Let paho handle reconnects (reconnect_delay_set configured)
287
+ _LOGGER.error("MQTT connection failed, return code: %s", rc)
288
+
289
+ def _on_disconnect(self, client: mqtt.Client, userdata: Any, rc: int) -> None:
290
+ """Called when the MQTT client disconnects from the broker.
291
+
292
+ Logs the disconnection. Automatic reconnects are handled by paho-mqtt.
293
+
294
+ Args:
295
+ client (mqtt.Client): The MQTT client instance.
296
+ userdata (Any): The user data passed to the client (not used).
297
+ rc (int): Disconnect result code. 0 indicates a clean disconnect.
298
+ """
299
+ _LOGGER.warning("Disconnected from EZVIZ MQTT broker (rc=%s)", rc)
300
+
301
+ def _on_message(
302
+ self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage
303
+ ) -> None:
304
+ """Handle incoming MQTT messages.
305
+
306
+ Decodes the payload, updates `messages_by_device` with the latest message,
307
+ and calls the optional user callback.
308
+
309
+ Args:
310
+ client (mqtt.Client): The MQTT client instance.
311
+ userdata (Any): The user data passed to the client (not used).
312
+ msg (mqtt.MQTTMessage): The MQTT message object containing payload and topic.
313
+ """
314
+ try:
315
+ decoded = self.decode_mqtt_message(msg.payload)
316
+ except PyEzvizError as err:
317
+ _LOGGER.warning("Failed to decode MQTT message: %s", err)
318
+ return
319
+
320
+ device_serial = decoded.get("ext", {}).get("device_serial")
321
+ if device_serial:
322
+ self._cache_message(device_serial, decoded)
323
+ _LOGGER.debug("Stored message for device_serial %s", device_serial)
324
+ else:
325
+ _LOGGER.warning("Received message with no device_serial: %s", decoded)
326
+
327
+ if self._on_message_callback:
328
+ try:
329
+ self._on_message_callback(decoded)
330
+ except Exception:
331
+ _LOGGER.exception("The on_message_callback raised")
332
+
333
+ # ------------------------------------------------------------------
334
+ # HTTP helpers
335
+ # ------------------------------------------------------------------
336
+
337
+ def _register_ezviz_push(self) -> None:
338
+ """Register the client with the Ezviz push service.
339
+
340
+ Sends the necessary information to Ezviz to obtain a unique MQTT client ID.
341
+
342
+ Raises:
343
+ PyEzvizError: If the registration fails or the API returns a non-200 status.
344
+ InvalidURL: If the push service URL is invalid or unreachable.
345
+ HTTPError: If the HTTP request fails for other reasons.
346
+ """
347
+ auth_seq = (
348
+ "Basic "
349
+ + base64.b64encode(f"{MQTT_APP_KEY}:{APP_SECRET}".encode("ascii")).decode()
350
+ )
351
+
352
+ payload = {
353
+ "appKey": MQTT_APP_KEY,
354
+ "clientType": "5",
355
+ "mac": FEATURE_CODE,
356
+ "token": "123456",
357
+ "version": "v1.3.0",
358
+ }
359
+
360
+ try:
361
+ req = self._session.post(
362
+ f"https://{self._mqtt_data['push_url']}{API_ENDPOINT_REGISTER_MQTT}",
363
+ allow_redirects=False,
364
+ headers={"Authorization": auth_seq},
365
+ data=payload,
366
+ timeout=self._timeout,
367
+ )
368
+ req.raise_for_status()
369
+ except requests.HTTPError as err: # network OK, HTTP error status
370
+ raise HTTPError from err
371
+
372
+ try:
373
+ json_output = req.json()
374
+ except requests.ConnectionError as err:
375
+ raise InvalidURL("Invalid URL or proxy error") from err
376
+ except ValueError as err:
377
+ raise PyEzvizError(
378
+ "Impossible to decode response: "
379
+ + str(err)
380
+ + "Response was: "
381
+ + str(req.text)
382
+ ) from err
383
+
384
+ if json_output.get("status") != 200:
385
+ raise PyEzvizError(
386
+ f"Could not register to EZVIZ mqtt server: Got {json_output})"
387
+ )
388
+
389
+ # Persist client id from payload
390
+ self._mqtt_data["mqtt_clientid"] = json_output["data"]["clientId"]
391
+
392
+ def _start_ezviz_push(self) -> None:
393
+ """Start push notifications for this client with the Ezviz API.
394
+
395
+ Sends the client ID, session ID, and username to Ezviz so that the server
396
+ will start pushing messages to this client.
397
+
398
+ Raises:
399
+ PyEzvizError: If the API fails to start push notifications or returns a non-200 status.
400
+ InvalidURL: If the push service URL is invalid or unreachable.
401
+ HTTPError: If the HTTP request fails for other reasons.
402
+ """
403
+ payload = {
404
+ "appKey": MQTT_APP_KEY,
405
+ "clientId": self._mqtt_data["mqtt_clientid"],
406
+ "clientType": 5,
407
+ "sessionId": self._token["session_id"],
408
+ "username": self._token["username"],
409
+ "token": "123456",
410
+ }
411
+
412
+ try:
413
+ req = self._session.post(
414
+ f"https://{self._mqtt_data['push_url']}{API_ENDPOINT_START_MQTT}",
415
+ allow_redirects=False,
416
+ data=payload,
417
+ timeout=self._timeout,
418
+ )
419
+ req.raise_for_status()
420
+ except requests.HTTPError as err:
421
+ raise HTTPError from err
422
+
423
+ try:
424
+ json_output = req.json()
425
+ except requests.ConnectionError as err:
426
+ raise InvalidURL("Invalid URL or proxy error") from err
427
+ except ValueError as err:
428
+ raise PyEzvizError(
429
+ "Impossible to decode response: "
430
+ + str(err)
431
+ + "Response was: "
432
+ + str(req.text)
433
+ ) from err
434
+
435
+ if json_output.get("status") != 200:
436
+ raise PyEzvizError(
437
+ f"Could not signal EZVIZ mqtt server to start pushing messages: Got {json_output})"
438
+ )
439
+
440
+ self._mqtt_data["ticket"] = json_output["ticket"]
441
+ _LOGGER.info("EZVIZ MQTT ticket acquired")
442
+
443
+ def _stop_ezviz_push(self) -> None:
444
+ """Stop push notifications for this client via the Ezviz API.
445
+
446
+ Sends the client ID and session information to stop further messages.
447
+
448
+ Raises:
449
+ PyEzvizError: If the API fails to stop push notifications or returns a non-200 status.
450
+ InvalidURL: If the push service URL is invalid or unreachable.
451
+ HTTPError: If the HTTP request fails for other reasons.
452
+ """
453
+ payload = {
454
+ "appKey": MQTT_APP_KEY,
455
+ "clientId": self._mqtt_data["mqtt_clientid"],
456
+ "clientType": 5,
457
+ "sessionId": self._token["session_id"],
458
+ "username": self._token["username"],
459
+ }
460
+
461
+ try:
462
+ req = self._session.post(
463
+ f"https://{self._mqtt_data['push_url']}{API_ENDPOINT_STOP_MQTT}",
464
+ data=payload,
465
+ timeout=self._timeout,
466
+ )
467
+ req.raise_for_status()
468
+ except requests.HTTPError as err:
469
+ raise HTTPError from err
470
+
471
+ try:
472
+ json_output = req.json()
473
+ except requests.ConnectionError as err:
474
+ raise InvalidURL("Invalid URL or proxy error") from err
475
+ except ValueError as err:
476
+ raise PyEzvizError(
477
+ "Impossible to decode response: "
478
+ + str(err)
479
+ + "Response was: "
480
+ + str(req.text)
481
+ ) from err
482
+
483
+ if json_output.get("status") != 200:
484
+ raise PyEzvizError(
485
+ f"Could not signal EZVIZ mqtt server to stop pushing messages: Got {json_output})"
486
+ )
487
+
488
+ # ------------------------------------------------------------------
489
+ # Internal helpers
490
+ # ------------------------------------------------------------------
491
+
492
+ def _configure_mqtt(self, *, clean_session: bool) -> None:
493
+ """Internal helper to configure and connect the paho-mqtt client.
494
+
495
+ This method sets up the MQTT client with:
496
+ - Callbacks for connect, disconnect, subscribe, and message
497
+ - Username and password authentication
498
+ - Reconnect delay settings
499
+ - Broker connection on the configured topic
500
+
501
+ Args:
502
+ clean_session (bool): Whether to start a clean MQTT session.
503
+
504
+ Notes:
505
+ This method is called automatically by `connect()`.
506
+
507
+ """
508
+ broker = self._mqtt_data["push_url"]
509
+
510
+ self.mqtt_client = mqtt.Client(
511
+ callback_api_version=mqtt.CallbackAPIVersion.VERSION1,
512
+ client_id=self._mqtt_data["mqtt_clientid"],
513
+ clean_session=clean_session,
514
+ protocol=mqtt.MQTTv311,
515
+ transport="tcp",
516
+ )
517
+
518
+ # Bind callbacks
519
+ self.mqtt_client.on_connect = self._on_connect
520
+ self.mqtt_client.on_disconnect = self._on_disconnect
521
+ self.mqtt_client.on_subscribe = self._on_subscribe
522
+ self.mqtt_client.on_message = self._on_message
523
+
524
+ # Auth (do not log these!)
525
+ self.mqtt_client.username_pw_set(MQTT_APP_KEY, APP_SECRET)
526
+
527
+ # Backoff for reconnects handled by paho
528
+ self.mqtt_client.reconnect_delay_set(min_delay=5, max_delay=10)
529
+
530
+ _LOGGER.debug("Configured MQTT client for broker %s", broker)
531
+
532
+ def _cache_message(self, device_serial: str, payload: dict[str, Any]) -> None:
533
+ """Cache latest message per device with an LRU-like policy.
534
+
535
+ Parameters:
536
+ device_serial (str): Device serial extracted from the message ``ext``.
537
+ payload (dict[str, Any]): Decoded message dictionary to store.
538
+ """
539
+ # Move existing to the end or insert new
540
+ if device_serial in self.messages_by_device:
541
+ del self.messages_by_device[device_serial]
542
+ self.messages_by_device[device_serial] = payload
543
+ # Evict oldest if above limit
544
+ while len(self.messages_by_device) > self._max_messages:
545
+ self.messages_by_device.popitem(last=False)
546
+
547
+ # ------------------------------------------------------------------
548
+ # Public decoding API
549
+ # ------------------------------------------------------------------
550
+
551
+ def decode_mqtt_message(self, payload_bytes: bytes) -> dict[str, Any]:
552
+ """Decode raw MQTT message payload into a structured dictionary.
553
+
554
+ The returned dictionary will contain all top-level fields from the message,
555
+ and the 'ext' field is parsed into named subfields with numeric fields converted to int.
556
+
557
+ Parameters:
558
+ payload_bytes (bytes): Raw payload received from MQTT broker.
559
+
560
+ Returns:
561
+ dict: Decoded message with ``ext`` mapped to named fields; numeric fields
562
+ converted to ``int`` where appropriate.
563
+
564
+ Raises:
565
+ PyEzvizError: If the payload is not valid JSON.
566
+ """
567
+ try:
568
+ payload_str = payload_bytes.decode("utf-8")
569
+ data: dict[str, Any] = json.loads(payload_str)
570
+
571
+ if "ext" in data and isinstance(data["ext"], str):
572
+ ext_parts = data["ext"].split(",")
573
+ ext_dict: dict[str, Any] = {}
574
+ for i, name in enumerate(EXT_FIELD_NAMES):
575
+ value: Any = ext_parts[i] if i < len(ext_parts) else None
576
+ if value is not None and name in EXT_INT_FIELDS:
577
+ with suppress(ValueError):
578
+ value = int(value)
579
+ ext_dict[name] = value
580
+ data["ext"] = ext_dict
581
+
582
+ except json.JSONDecodeError as err:
583
+ # Stop the client on malformed payloads as a defensive measure,
584
+ # mirroring previous behaviour.
585
+ self.stop()
586
+ raise PyEzvizError(f"Unable to decode MQTT message: {err}") from err
587
+
588
+ return data
@@ -0,0 +1,64 @@
1
+ """MQTT test module."""
2
+
3
+ from getpass import getpass
4
+ import json
5
+ import logging
6
+ from pathlib import Path
7
+ import time
8
+ from typing import Any
9
+
10
+ from .client import EzvizClient # Your login client
11
+ from .mqtt import MQTTClient # The refactored MQTT class
12
+
13
+ logging.basicConfig(level=logging.INFO)
14
+
15
+ LOG_FILE = Path("mqtt_messages.jsonl") # JSON Lines format
16
+
17
+
18
+ def message_handler(msg: dict[str, Any]) -> None:
19
+ """Handle new MQTT messages by printing and saving them to a file.
20
+
21
+ Args:
22
+ msg (Dict[str, Any]): The decoded MQTT message.
23
+ """
24
+ print("📩 New MQTT message:", msg)
25
+
26
+ # Append to JSONL file
27
+ with LOG_FILE.open("a", encoding="utf-8") as f:
28
+ f.write(json.dumps(msg, ensure_ascii=False) + "\n")
29
+
30
+
31
+ def main() -> None:
32
+ """Entry point for testing MQTT messages.
33
+
34
+ Prompts for username and password, logs into Ezviz, starts MQTT listener,
35
+ and writes incoming messages to a JSONL file.
36
+ """
37
+ # Prompt for credentials
38
+ username = input("Ezviz username: ")
39
+ password = getpass("Ezviz password: ")
40
+
41
+ # Step 1: Log into Ezviz to get a token
42
+ client = EzvizClient(account=username, password=password)
43
+ client.login()
44
+
45
+ # Step 2: Start MQTT client
46
+ mqtt_client: MQTTClient = client.get_mqtt_client(
47
+ on_message_callback=message_handler
48
+ )
49
+ mqtt_client.connect()
50
+
51
+ try:
52
+ print("Listening for MQTT messages... (Ctrl+C to quit)")
53
+ while True:
54
+ time.sleep(1) # Keep process alive
55
+ except KeyboardInterrupt:
56
+ print("\nStopping...")
57
+ finally:
58
+ mqtt_client.stop()
59
+ print("Stopped.")
60
+
61
+
62
+ if __name__ == "__main__":
63
+ main()
64
+
@@ -6,6 +6,7 @@ from hashlib import md5
6
6
  import json
7
7
  import logging
8
8
  from typing import Any
9
+ import uuid
9
10
 
10
11
  from Crypto.Cipher import AES
11
12
 
@@ -61,10 +62,12 @@ def fetch_nested_value(data: Any, keys: list, default_value: Any = None) -> Any:
61
62
  try:
62
63
  for key in keys:
63
64
  data = data[key]
64
- return data
65
+
65
66
  except (KeyError, TypeError):
66
67
  return default_value
67
68
 
69
+ return data
70
+
68
71
 
69
72
  def decrypt_image(input_data: bytes, password: str) -> bytes:
70
73
  """Decrypts image data with provided password.
@@ -162,3 +165,27 @@ def deep_merge(dict1: Any, dict2: Any) -> Any:
162
165
  merged[key] = dict2[key]
163
166
 
164
167
  return merged
168
+
169
+
170
+ def generate_unique_code() -> str:
171
+ """Generate a deterministic, platform-agnostic unique code for the current host.
172
+
173
+ This function retrieves the host's MAC address using Python's standard
174
+ `uuid.getnode()` (works on Windows, Linux, macOS), converts it to a
175
+ canonical string representation, and then hashes it using MD5 to produce
176
+ a fixed-length hexadecimal string.
177
+
178
+ Returns:
179
+ str: A 32-character hexadecimal string uniquely representing
180
+ the host's MAC address. For example:
181
+ 'a94e6756hghjgfghg49e0f310d9e44a'.
182
+
183
+ Notes:
184
+ - The output is deterministic: the same machine returns the same code.
185
+ - If the MAC address changes (e.g., different network adapter),
186
+ the output will change.
187
+ - MD5 is used here only for ID generation, not for security.
188
+ """
189
+ mac_int = uuid.getnode()
190
+ mac_str = ":".join(f"{(mac_int >> i) & 0xFF:02x}" for i in range(40, -1, -8))
191
+ return md5(mac_str.encode("utf-8")).hexdigest()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyezvizapi
3
- Version: 1.0.1.6
3
+ Version: 1.0.1.7
4
4
  Summary: Pilot your Ezviz cameras
5
5
  Home-page: https://github.com/RenierM26/pyEzvizApi/
6
6
  Author: Renier Moorcroft
@@ -14,6 +14,7 @@ pyezvizapi/exceptions.py
14
14
  pyezvizapi/light_bulb.py
15
15
  pyezvizapi/mqtt.py
16
16
  pyezvizapi/test_cam_rtsp.py
17
+ pyezvizapi/test_mqtt.py
17
18
  pyezvizapi/utils.py
18
19
  pyezvizapi.egg-info/PKG-INFO
19
20
  pyezvizapi.egg-info/SOURCES.txt
@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
5
5
 
6
6
  setuptools.setup(
7
7
  name='pyezvizapi',
8
- version="1.0.1.6",
8
+ version="1.0.1.7",
9
9
  license='Apache Software License 2.0',
10
10
  author='Renier Moorcroft',
11
11
  author_email='RenierM26@users.github.com',
@@ -1,259 +0,0 @@
1
- """Ezviz cloud MQTT client for push messages."""
2
- from __future__ import annotations
3
-
4
- import base64
5
- import json
6
- import logging
7
- import threading
8
- import time
9
- from typing import Any
10
-
11
- import paho.mqtt.client as mqtt
12
- import requests
13
-
14
- from .api_endpoints import (
15
- API_ENDPOINT_REGISTER_MQTT,
16
- API_ENDPOINT_START_MQTT,
17
- API_ENDPOINT_STOP_MQTT,
18
- )
19
- from .constants import (
20
- APP_SECRET,
21
- DEFAULT_TIMEOUT,
22
- FEATURE_CODE,
23
- MQTT_APP_KEY,
24
- REQUEST_HEADER,
25
- )
26
- from .exceptions import HTTPError, InvalidURL, PyEzvizError
27
-
28
- _LOGGER = logging.getLogger(__name__)
29
-
30
-
31
- class MQTTClient(threading.Thread):
32
- """Open MQTT connection to ezviz cloud."""
33
-
34
- def __init__(
35
- self,
36
- token: dict,
37
- timeout: int = DEFAULT_TIMEOUT,
38
- ) -> None:
39
- """Initialize the client object."""
40
- threading.Thread.__init__(self)
41
- self._session = requests.session()
42
- self._session.headers.update(REQUEST_HEADER)
43
- self._token = token or {
44
- "session_id": None,
45
- "rf_session_id": None,
46
- "username": None,
47
- "api_url": "apiieu.ezvizlife.com",
48
- }
49
- self._timeout = timeout
50
- self._stop_event = threading.Event()
51
- self._mqtt_data = {
52
- "mqtt_clientid": None,
53
- "ticket": None,
54
- "push_url": token["service_urls"]["pushAddr"],
55
- }
56
- self.mqtt_client = None
57
- self.rcv_message: dict[Any, Any] = {}
58
-
59
- def on_subscribe(
60
- self, client: Any, userdata: Any, mid: Any, granted_qos: Any
61
- ) -> None:
62
- """On MQTT message subscribe."""
63
- # pylint: disable=unused-argument
64
- _LOGGER.info("Subscribed: %s %s", mid, granted_qos)
65
-
66
- def on_connect(
67
- self, client: Any, userdata: Any, flags: Any, return_code: Any
68
- ) -> None:
69
- """On MQTT connect."""
70
- # pylint: disable=unused-argument
71
- if return_code == 0:
72
- _LOGGER.info("Connected OK with return code %s", return_code)
73
- else:
74
- _LOGGER.info("Connection Error with Return code %s", return_code)
75
- client.reconnect()
76
-
77
- def on_message(self, client: Any, userdata: Any, msg: Any) -> None:
78
- """On MQTT message receive."""
79
- # pylint: disable=unused-argument
80
- try:
81
- mqtt_message = json.loads(msg.payload)
82
-
83
- except ValueError as err:
84
- self.stop()
85
- raise PyEzvizError(
86
- "Impossible to decode mqtt message: " + str(err)
87
- ) from err
88
-
89
- mqtt_message["ext"] = mqtt_message["ext"].split(",")
90
-
91
- # Format payload message and keep latest device message.
92
- self.rcv_message[mqtt_message["ext"][2]] = {
93
- "id": mqtt_message["id"],
94
- "alert": mqtt_message["alert"],
95
- "time": mqtt_message["ext"][1],
96
- "alert type": mqtt_message["ext"][4],
97
- "image": mqtt_message["ext"][16] if len(mqtt_message["ext"]) > 16 else None,
98
- }
99
-
100
- _LOGGER.debug(self.rcv_message, exc_info=True)
101
-
102
- def _mqtt(self) -> mqtt.Client:
103
- """Receive MQTT messages from ezviz server."""
104
-
105
- ezviz_mqtt_client = mqtt.Client(
106
- client_id=self._mqtt_data["mqtt_clientid"], protocol=4, transport="tcp"
107
- )
108
- ezviz_mqtt_client.on_connect = self.on_connect
109
- ezviz_mqtt_client.on_subscribe = self.on_subscribe
110
- ezviz_mqtt_client.on_message = self.on_message
111
- ezviz_mqtt_client.username_pw_set(MQTT_APP_KEY, APP_SECRET)
112
-
113
- ezviz_mqtt_client.connect(self._mqtt_data["push_url"], 1882, 60)
114
- ezviz_mqtt_client.subscribe(
115
- f"{MQTT_APP_KEY}/ticket/{self._mqtt_data['ticket']}", qos=2
116
- )
117
-
118
- ezviz_mqtt_client.loop_start()
119
- return ezviz_mqtt_client
120
-
121
- def _register_ezviz_push(self) -> None:
122
- """Register for push messages."""
123
-
124
- auth_seq = (
125
- "Basic "
126
- + base64.b64encode(f"{MQTT_APP_KEY}:{APP_SECRET}".encode("ascii")).decode()
127
- )
128
-
129
- payload = {
130
- "appKey": MQTT_APP_KEY,
131
- "clientType": "5",
132
- "mac": FEATURE_CODE,
133
- "token": "123456",
134
- "version": "v1.3.0",
135
- }
136
-
137
- try:
138
- req = self._session.post(
139
- f"https://{self._mqtt_data['push_url']}{API_ENDPOINT_REGISTER_MQTT}",
140
- allow_redirects=False,
141
- headers={"Authorization": auth_seq},
142
- data=payload,
143
- timeout=self._timeout,
144
- )
145
-
146
- req.raise_for_status()
147
-
148
- except requests.ConnectionError as err:
149
- raise InvalidURL("A Invalid URL or Proxy error occured") from err
150
-
151
- except requests.HTTPError as err:
152
- raise HTTPError from err
153
-
154
- try:
155
- json_result = req.json()
156
-
157
- except ValueError as err:
158
- raise PyEzvizError(
159
- "Impossible to decode response: "
160
- + str(err)
161
- + "\nResponse was: "
162
- + str(req.text)
163
- ) from err
164
-
165
- self._mqtt_data["mqtt_clientid"] = json_result["data"]["clientId"]
166
-
167
- def run(self) -> None:
168
- """Start mqtt thread."""
169
-
170
- if self._token.get("username") is None:
171
- self._stop_event.set()
172
- raise PyEzvizError(
173
- "Ezviz internal username is required. Call EzvizClient login without token."
174
- )
175
-
176
- self._register_ezviz_push()
177
- self._start_ezviz_push()
178
- self.mqtt_client = self._mqtt()
179
-
180
- def start(self) -> None:
181
- """Start mqtt thread as application. Set logging first to see messages."""
182
- self.run()
183
-
184
- try:
185
- while not self._stop_event.is_set():
186
- time.sleep(1)
187
- except KeyboardInterrupt:
188
- self.stop()
189
-
190
- def stop(self) -> None:
191
- """Stop push notifications."""
192
-
193
- payload = {
194
- "appKey": MQTT_APP_KEY,
195
- "clientId": self._mqtt_data["mqtt_clientid"],
196
- "clientType": 5,
197
- "sessionId": self._token["session_id"],
198
- "username": self._token["username"],
199
- }
200
-
201
- try:
202
- req = self._session.post(
203
- f"https://{self._mqtt_data['push_url']}{API_ENDPOINT_STOP_MQTT}",
204
- data=payload,
205
- timeout=self._timeout,
206
- )
207
-
208
- req.raise_for_status()
209
-
210
- except requests.ConnectionError as err:
211
- raise InvalidURL("A Invalid URL or Proxy error occured") from err
212
-
213
- except requests.HTTPError as err:
214
- raise HTTPError from err
215
-
216
- finally:
217
- self._stop_event.set()
218
- self.mqtt_client.loop_stop()
219
-
220
- def _start_ezviz_push(self) -> None:
221
- """Send start for push messages to ezviz api."""
222
-
223
- payload = {
224
- "appKey": MQTT_APP_KEY,
225
- "clientId": self._mqtt_data["mqtt_clientid"],
226
- "clientType": 5,
227
- "sessionId": self._token["session_id"],
228
- "username": self._token["username"],
229
- "token": "123456",
230
- }
231
-
232
- try:
233
- req = self._session.post(
234
- f"https://{self._mqtt_data['push_url']}{API_ENDPOINT_START_MQTT}",
235
- allow_redirects=False,
236
- data=payload,
237
- timeout=self._timeout,
238
- )
239
-
240
- req.raise_for_status()
241
-
242
- except requests.ConnectionError as err:
243
- raise InvalidURL("A Invalid URL or Proxy error occured") from err
244
-
245
- except requests.HTTPError as err:
246
- raise HTTPError from err
247
-
248
- try:
249
- json_result = req.json()
250
-
251
- except ValueError as err:
252
- raise PyEzvizError(
253
- "Impossible to decode response: "
254
- + str(err)
255
- + "\nResponse was: "
256
- + str(req.text)
257
- ) from err
258
-
259
- self._mqtt_data["ticket"] = json_result["ticket"]
File without changes
File without changes
File without changes
File without changes
File without changes