pyezvizapi 1.0.1.6__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/mqtt.py CHANGED
@@ -1,12 +1,32 @@
1
- """Ezviz cloud MQTT client for push messages."""
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
+
2
21
  from __future__ import annotations
3
22
 
4
23
  import base64
24
+ from collections import OrderedDict
25
+ from collections.abc import Callable
26
+ from contextlib import suppress
5
27
  import json
6
28
  import logging
7
- import threading
8
- import time
9
- from typing import Any
29
+ from typing import Any, Final, TypedDict
10
30
 
11
31
  import paho.mqtt.client as mqtt
12
32
  import requests
@@ -16,111 +36,340 @@ from .api_endpoints import (
16
36
  API_ENDPOINT_START_MQTT,
17
37
  API_ENDPOINT_STOP_MQTT,
18
38
  )
19
- from .constants import (
20
- APP_SECRET,
21
- DEFAULT_TIMEOUT,
22
- FEATURE_CODE,
23
- MQTT_APP_KEY,
24
- REQUEST_HEADER,
25
- )
39
+ from .constants import APP_SECRET, DEFAULT_TIMEOUT, FEATURE_CODE, MQTT_APP_KEY
26
40
  from .exceptions import HTTPError, InvalidURL, PyEzvizError
27
41
 
28
42
  _LOGGER = logging.getLogger(__name__)
29
43
 
30
44
 
31
- class MQTTClient(threading.Thread):
32
- """Open MQTT connection to ezviz cloud."""
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
+ "picChecksum",
101
+ "unknown_flag",
102
+ "unused5",
103
+ "msgId",
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
+ """
33
148
 
34
149
  def __init__(
35
150
  self,
36
- token: dict,
151
+ token: EzvizToken | dict,
152
+ session: requests.Session,
37
153
  timeout: int = DEFAULT_TIMEOUT,
154
+ on_message_callback: Callable[[dict[str, Any]], None] | None = None,
155
+ *,
156
+ max_messages: int = 1000,
38
157
  ) -> 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 = {
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 = {
52
196
  "mqtt_clientid": None,
53
197
  "ticket": None,
54
198
  "push_url": token["service_urls"]["pushAddr"],
55
199
  }
56
- self.mqtt_client = None
57
- self.rcv_message: dict[Any, Any] = {}
58
200
 
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)
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()
65
204
 
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()
205
+ # ------------------------------------------------------------------
206
+ # Public API
207
+ # ------------------------------------------------------------------
76
208
 
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)
209
+ def connect(self, *, clean_session: bool = False, keepalive: int = 60) -> None:
210
+ """Connect to the Ezviz MQTT broker and start receiving push messages.
82
211
 
83
- except ValueError as err:
84
- self.stop()
85
- raise PyEzvizError(
86
- "Impossible to decode mqtt message: " + str(err)
87
- ) from err
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.
88
217
 
89
- mqtt_message["ext"] = mqtt_message["ext"].split(",")
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.
90
221
 
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
- }
222
+ Raises:
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.
226
+ """
227
+ self._register_ezviz_push()
228
+ self._start_ezviz_push()
229
+ self._configure_mqtt(clean_session=clean_session)
230
+ assert self.mqtt_client is not None
231
+ self.mqtt_client.connect(self._mqtt_data["push_url"], 1882, keepalive)
232
+ self.mqtt_client.loop_start()
99
233
 
100
- _LOGGER.debug(self.rcv_message, exc_info=True)
234
+ def stop(self) -> None:
235
+ """Stop the MQTT client and push notifications.
236
+
237
+ This method stops the MQTT network loop, disconnects from the broker,
238
+ and signals the Ezviz API to stop push notifications.
239
+
240
+ This method is idempotent and can be called multiple times safely.
241
+
242
+ Raises:
243
+ PyEzvizError: If stopping the push service fails.
244
+ """
245
+ if self.mqtt_client:
246
+ try:
247
+ # Stop background thread and disconnect
248
+ self.mqtt_client.loop_stop()
249
+ self.mqtt_client.disconnect()
250
+ except Exception as err: # noqa: BLE001
251
+ _LOGGER.debug("MQTT disconnect failed: %s", err)
252
+ # Always attempt to stop push on server side
253
+ self._stop_ezviz_push()
254
+
255
+ # ------------------------------------------------------------------
256
+ # MQTT callbacks
257
+ # ------------------------------------------------------------------
258
+
259
+ def _on_subscribe(
260
+ self, client: mqtt.Client, userdata: Any, mid: int, granted_qos: tuple[int, ...]
261
+ ) -> None:
262
+ """Handle subscription acknowledgement from the broker."""
263
+ _LOGGER.debug(
264
+ "MQTT subscribed: topic=%s mid=%s qos=%s", self._topic, mid, granted_qos
265
+ )
101
266
 
102
- def _mqtt(self) -> mqtt.Client:
103
- """Receive MQTT messages from ezviz server."""
267
+ def _on_connect(
268
+ self, client: mqtt.Client, userdata: Any, flags: dict, rc: int
269
+ ) -> None:
270
+ """Handle successful or failed MQTT connection attempts.
271
+
272
+ Subscribes to the topic if this is a new session and logs connection status.
273
+
274
+ Args:
275
+ client (mqtt.Client): The MQTT client instance.
276
+ userdata (Any): The user data passed to the client (not used).
277
+ flags (dict): MQTT flags dictionary, includes 'session present'.
278
+ rc (int): MQTT connection result code. 0 indicates success.
279
+ """
280
+ session_present = (
281
+ flags.get("session present") if isinstance(flags, dict) else None
282
+ )
283
+ _LOGGER.debug("MQTT connected: rc=%s session_present=%s", rc, session_present)
284
+ if rc == 0 and not session_present:
285
+ client.subscribe(self._topic, qos=2)
286
+ if rc != 0:
287
+ # Let paho handle reconnects (reconnect_delay_set configured)
288
+ _LOGGER.error(
289
+ "MQTT connect failed: serial=%s code=%s msg=%s",
290
+ "unknown",
291
+ rc,
292
+ "connect_failed",
293
+ )
104
294
 
105
- ezviz_mqtt_client = mqtt.Client(
106
- client_id=self._mqtt_data["mqtt_clientid"], protocol=4, transport="tcp"
295
+ def _on_disconnect(self, client: mqtt.Client, userdata: Any, rc: int) -> None:
296
+ """Called when the MQTT client disconnects from the broker.
297
+
298
+ Logs the disconnection. Automatic reconnects are handled by paho-mqtt.
299
+
300
+ Args:
301
+ client (mqtt.Client): The MQTT client instance.
302
+ userdata (Any): The user data passed to the client (not used).
303
+ rc (int): Disconnect result code. 0 indicates a clean disconnect.
304
+ """
305
+ _LOGGER.debug(
306
+ "MQTT disconnected: serial=%s code=%s msg=%s",
307
+ "unknown",
308
+ rc,
309
+ "disconnected",
107
310
  )
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
311
+
312
+ def _on_message(
313
+ self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage
314
+ ) -> None:
315
+ """Handle incoming MQTT messages.
316
+
317
+ Decodes the payload, updates `messages_by_device` with the latest message,
318
+ and calls the optional user callback.
319
+
320
+ Args:
321
+ client (mqtt.Client): The MQTT client instance.
322
+ userdata (Any): The user data passed to the client (not used).
323
+ msg (mqtt.MQTTMessage): The MQTT message object containing payload and topic.
324
+ """
325
+ try:
326
+ decoded = self.decode_mqtt_message(msg.payload)
327
+ except PyEzvizError as err:
328
+ _LOGGER.warning("MQTT_decode_error: msg=%s", str(err))
329
+ return
330
+
331
+ ext: dict[str, Any] = (
332
+ decoded.get("ext", {}) if isinstance(decoded.get("ext"), dict) else {}
116
333
  )
334
+ device_serial = ext.get("device_serial")
335
+ alert_code = ext.get("alert_type_code")
336
+ msg_id = ext.get("msgId")
337
+
338
+ if device_serial:
339
+ self._cache_message(device_serial, decoded)
340
+ _LOGGER.debug(
341
+ "mqtt_msg: serial=%s alert_code=%s msg_id=%s",
342
+ device_serial,
343
+ alert_code,
344
+ msg_id,
345
+ )
346
+ else:
347
+ _LOGGER.warning(
348
+ "MQTT_message_missing_serial: alert_code=%s msg_id=%s",
349
+ alert_code,
350
+ msg_id,
351
+ )
352
+
353
+ if self._on_message_callback:
354
+ try:
355
+ self._on_message_callback(decoded)
356
+ except Exception:
357
+ _LOGGER.exception("The on_message_callback raised")
117
358
 
118
- ezviz_mqtt_client.loop_start()
119
- return ezviz_mqtt_client
359
+ # ------------------------------------------------------------------
360
+ # HTTP helpers
361
+ # ------------------------------------------------------------------
120
362
 
121
363
  def _register_ezviz_push(self) -> None:
122
- """Register for push messages."""
364
+ """Register the client with the Ezviz push service.
123
365
 
366
+ Sends the necessary information to Ezviz to obtain a unique MQTT client ID.
367
+
368
+ Raises:
369
+ PyEzvizError: If the registration fails or the API returns a non-200 status.
370
+ InvalidURL: If the push service URL is invalid or unreachable.
371
+ HTTPError: If the HTTP request fails for other reasons.
372
+ """
124
373
  auth_seq = (
125
374
  "Basic "
126
375
  + base64.b64encode(f"{MQTT_APP_KEY}:{APP_SECRET}".encode("ascii")).decode()
@@ -142,118 +391,226 @@ class MQTTClient(threading.Thread):
142
391
  data=payload,
143
392
  timeout=self._timeout,
144
393
  )
145
-
146
394
  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:
395
+ except requests.HTTPError as err: # network OK, HTTP error status
152
396
  raise HTTPError from err
153
397
 
154
398
  try:
155
- json_result = req.json()
156
-
399
+ json_output = req.json()
400
+ except requests.ConnectionError as err:
401
+ raise InvalidURL("Invalid URL or proxy error") from err
157
402
  except ValueError as err:
158
403
  raise PyEzvizError(
159
404
  "Impossible to decode response: "
160
405
  + str(err)
161
- + "\nResponse was: "
406
+ + "Response was: "
162
407
  + str(req.text)
163
408
  ) from err
164
409
 
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()
410
+ if json_output.get("status") != 200:
172
411
  raise PyEzvizError(
173
- "Ezviz internal username is required. Call EzvizClient login without token."
412
+ f"Could not register to EZVIZ mqtt server: Got {json_output})"
174
413
  )
175
414
 
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()
415
+ # Persist client id from payload
416
+ self._mqtt_data["mqtt_clientid"] = json_output["data"]["clientId"]
183
417
 
184
- try:
185
- while not self._stop_event.is_set():
186
- time.sleep(1)
187
- except KeyboardInterrupt:
188
- self.stop()
418
+ def _start_ezviz_push(self) -> None:
419
+ """Start push notifications for this client with the Ezviz API.
189
420
 
190
- def stop(self) -> None:
191
- """Stop push notifications."""
421
+ Sends the client ID, session ID, and username to Ezviz so that the server
422
+ will start pushing messages to this client.
192
423
 
424
+ Raises:
425
+ PyEzvizError: If the API fails to start push notifications or returns a non-200 status.
426
+ InvalidURL: If the push service URL is invalid or unreachable.
427
+ HTTPError: If the HTTP request fails for other reasons.
428
+ """
193
429
  payload = {
194
430
  "appKey": MQTT_APP_KEY,
195
431
  "clientId": self._mqtt_data["mqtt_clientid"],
196
432
  "clientType": 5,
197
433
  "sessionId": self._token["session_id"],
198
434
  "username": self._token["username"],
435
+ "token": "123456",
199
436
  }
200
437
 
201
438
  try:
202
439
  req = self._session.post(
203
- f"https://{self._mqtt_data['push_url']}{API_ENDPOINT_STOP_MQTT}",
440
+ f"https://{self._mqtt_data['push_url']}{API_ENDPOINT_START_MQTT}",
441
+ allow_redirects=False,
204
442
  data=payload,
205
443
  timeout=self._timeout,
206
444
  )
207
-
208
445
  req.raise_for_status()
446
+ except requests.HTTPError as err:
447
+ raise HTTPError from err
209
448
 
449
+ try:
450
+ json_output = req.json()
210
451
  except requests.ConnectionError as err:
211
- raise InvalidURL("A Invalid URL or Proxy error occured") from err
452
+ raise InvalidURL("Invalid URL or proxy error") from err
453
+ except ValueError as err:
454
+ raise PyEzvizError(
455
+ "Impossible to decode response: "
456
+ + str(err)
457
+ + "Response was: "
458
+ + str(req.text)
459
+ ) from err
212
460
 
213
- except requests.HTTPError as err:
214
- raise HTTPError from err
461
+ if json_output.get("status") != 200:
462
+ raise PyEzvizError(
463
+ f"Could not signal EZVIZ mqtt server to start pushing messages: Got {json_output})"
464
+ )
215
465
 
216
- finally:
217
- self._stop_event.set()
218
- self.mqtt_client.loop_stop()
466
+ self._mqtt_data["ticket"] = json_output["ticket"]
467
+ _LOGGER.debug(
468
+ "MQTT ticket acquired: client_id=%s", self._mqtt_data["mqtt_clientid"]
469
+ )
219
470
 
220
- def _start_ezviz_push(self) -> None:
221
- """Send start for push messages to ezviz api."""
471
+ def _stop_ezviz_push(self) -> None:
472
+ """Stop push notifications for this client via the Ezviz API.
222
473
 
474
+ Sends the client ID and session information to stop further messages.
475
+
476
+ Raises:
477
+ PyEzvizError: If the API fails to stop push notifications or returns a non-200 status.
478
+ InvalidURL: If the push service URL is invalid or unreachable.
479
+ HTTPError: If the HTTP request fails for other reasons.
480
+ """
223
481
  payload = {
224
482
  "appKey": MQTT_APP_KEY,
225
483
  "clientId": self._mqtt_data["mqtt_clientid"],
226
484
  "clientType": 5,
227
485
  "sessionId": self._token["session_id"],
228
486
  "username": self._token["username"],
229
- "token": "123456",
230
487
  }
231
488
 
232
489
  try:
233
490
  req = self._session.post(
234
- f"https://{self._mqtt_data['push_url']}{API_ENDPOINT_START_MQTT}",
235
- allow_redirects=False,
491
+ f"https://{self._mqtt_data['push_url']}{API_ENDPOINT_STOP_MQTT}",
236
492
  data=payload,
237
493
  timeout=self._timeout,
238
494
  )
239
-
240
495
  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
496
  except requests.HTTPError as err:
246
497
  raise HTTPError from err
247
498
 
248
499
  try:
249
- json_result = req.json()
250
-
500
+ json_output = req.json()
501
+ except requests.ConnectionError as err:
502
+ raise InvalidURL("Invalid URL or proxy error") from err
251
503
  except ValueError as err:
252
504
  raise PyEzvizError(
253
505
  "Impossible to decode response: "
254
506
  + str(err)
255
- + "\nResponse was: "
507
+ + "Response was: "
256
508
  + str(req.text)
257
509
  ) from err
258
510
 
259
- self._mqtt_data["ticket"] = json_result["ticket"]
511
+ if json_output.get("status") != 200:
512
+ raise PyEzvizError(
513
+ f"Could not signal EZVIZ mqtt server to stop pushing messages: Got {json_output})"
514
+ )
515
+
516
+ # ------------------------------------------------------------------
517
+ # Internal helpers
518
+ # ------------------------------------------------------------------
519
+
520
+ def _configure_mqtt(self, *, clean_session: bool) -> None:
521
+ """Internal helper to configure and connect the paho-mqtt client.
522
+
523
+ This method sets up the MQTT client with:
524
+ - Callbacks for connect, disconnect, subscribe, and message
525
+ - Username and password authentication
526
+ - Reconnect delay settings
527
+ - Broker connection on the configured topic
528
+
529
+ Args:
530
+ clean_session (bool): Whether to start a clean MQTT session.
531
+
532
+ Notes:
533
+ This method is called automatically by `connect()`.
534
+
535
+ """
536
+ broker = self._mqtt_data["push_url"]
537
+
538
+ self.mqtt_client = mqtt.Client(
539
+ callback_api_version=mqtt.CallbackAPIVersion.VERSION1,
540
+ client_id=self._mqtt_data["mqtt_clientid"],
541
+ clean_session=clean_session,
542
+ protocol=mqtt.MQTTv311,
543
+ transport="tcp",
544
+ )
545
+
546
+ # Bind callbacks
547
+ self.mqtt_client.on_connect = self._on_connect
548
+ self.mqtt_client.on_disconnect = self._on_disconnect
549
+ self.mqtt_client.on_subscribe = self._on_subscribe
550
+ self.mqtt_client.on_message = self._on_message
551
+
552
+ # Auth (do not log these!)
553
+ self.mqtt_client.username_pw_set(MQTT_APP_KEY, APP_SECRET)
554
+
555
+ # Backoff for reconnects handled by paho
556
+ self.mqtt_client.reconnect_delay_set(min_delay=5, max_delay=10)
557
+
558
+ _LOGGER.debug("Configured MQTT client for broker %s", broker)
559
+
560
+ def _cache_message(self, device_serial: str, payload: dict[str, Any]) -> None:
561
+ """Cache latest message per device with an LRU-like policy.
562
+
563
+ Parameters:
564
+ device_serial (str): Device serial extracted from the message ``ext``.
565
+ payload (dict[str, Any]): Decoded message dictionary to store.
566
+ """
567
+ # Move existing to the end or insert new
568
+ if device_serial in self.messages_by_device:
569
+ del self.messages_by_device[device_serial]
570
+ self.messages_by_device[device_serial] = payload
571
+ # Evict oldest if above limit
572
+ while len(self.messages_by_device) > self._max_messages:
573
+ self.messages_by_device.popitem(last=False)
574
+
575
+ # ------------------------------------------------------------------
576
+ # Public decoding API
577
+ # ------------------------------------------------------------------
578
+
579
+ def decode_mqtt_message(self, payload_bytes: bytes) -> dict[str, Any]:
580
+ """Decode raw MQTT message payload into a structured dictionary.
581
+
582
+ The returned dictionary will contain all top-level fields from the message,
583
+ and the 'ext' field is parsed into named subfields with numeric fields converted to int.
584
+
585
+ Parameters:
586
+ payload_bytes (bytes): Raw payload received from MQTT broker.
587
+
588
+ Returns:
589
+ dict: Decoded message with ``ext`` mapped to named fields; numeric fields
590
+ converted to ``int`` where appropriate.
591
+
592
+ Raises:
593
+ PyEzvizError: If the payload is not valid JSON.
594
+ """
595
+ try:
596
+ payload_str = payload_bytes.decode("utf-8")
597
+ data: dict[str, Any] = json.loads(payload_str)
598
+
599
+ if "ext" in data and isinstance(data["ext"], str):
600
+ ext_parts = data["ext"].split(",")
601
+ ext_dict: dict[str, Any] = {}
602
+ for i, name in enumerate(EXT_FIELD_NAMES):
603
+ value: Any = ext_parts[i] if i < len(ext_parts) else None
604
+ if value is not None and name in EXT_INT_FIELDS:
605
+ with suppress(ValueError):
606
+ value = int(value)
607
+ ext_dict[name] = value
608
+ data["ext"] = ext_dict
609
+
610
+ except json.JSONDecodeError as err:
611
+ # Stop the client on malformed payloads as a defensive measure,
612
+ # mirroring previous behaviour.
613
+ self.stop()
614
+ raise PyEzvizError(f"Unable to decode MQTT message: {err}") from err
615
+
616
+ return data