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/client.py CHANGED
@@ -2,11 +2,12 @@
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
8
9
  import logging
9
- from typing import Any
10
+ from typing import Any, ClassVar, TypedDict, cast
10
11
  import urllib.parse
11
12
  from uuid import uuid4
12
13
 
@@ -79,14 +80,95 @@ from .exceptions import (
79
80
  PyEzvizError,
80
81
  )
81
82
  from .light_bulb import EzvizLightBulb
83
+ from .models import EzvizDeviceRecord, build_device_records_map
84
+ from .mqtt import MQTTClient
82
85
  from .utils import convert_to_dict, deep_merge
83
86
 
84
87
  _LOGGER = logging.getLogger(__name__)
85
88
 
86
89
 
90
+ class ClientToken(TypedDict, total=False):
91
+ """Typed shape for the Ezviz client token."""
92
+
93
+ session_id: str | None
94
+ rf_session_id: str | None
95
+ username: str | None
96
+ api_url: str
97
+ service_urls: dict[str, Any]
98
+
99
+
100
+ class MetaDict(TypedDict, total=False):
101
+ """Shape of the common 'meta' object used by the Ezviz API."""
102
+
103
+ code: int
104
+ message: str
105
+ moreInfo: Any
106
+
107
+
108
+ class ApiOkResponse(TypedDict, total=False):
109
+ """Container for API responses that include a top-level 'meta'."""
110
+
111
+ meta: MetaDict
112
+
113
+
114
+ class ResultCodeResponse(TypedDict, total=False):
115
+ """Legacy-style API response using 'resultCode'."""
116
+
117
+ resultCode: str | int
118
+
119
+
120
+ class StorageStatusResponse(ResultCodeResponse, total=False):
121
+ """Response for storage status queries."""
122
+
123
+ storageStatus: Any
124
+
125
+
126
+ class CamKeyResponse(ResultCodeResponse, total=False):
127
+ """Response for camera encryption key retrieval."""
128
+
129
+ encryptkey: str
130
+ resultDes: str
131
+
132
+
133
+ class SystemInfoResponse(TypedDict, total=False):
134
+ """System info response including configuration details."""
135
+
136
+ systemConfigInfo: dict[str, Any]
137
+
138
+
139
+ class PagelistPageInfo(TypedDict, total=False):
140
+ """Pagination info with 'hasNext' flag."""
141
+
142
+ hasNext: bool
143
+
144
+
145
+ class PagelistResponse(ApiOkResponse, total=False):
146
+ """Pagelist response wrapper; other keys are dynamic per filter."""
147
+
148
+ page: PagelistPageInfo
149
+ # other keys are dynamic; callers select via json_key
150
+
151
+
152
+ class UserIdResponse(ApiOkResponse, total=False):
153
+ """User ID response holding device token info used by restricted APIs."""
154
+
155
+ deviceTokenInfo: Any
156
+
157
+
87
158
  class EzvizClient:
88
159
  """Initialize api client object."""
89
160
 
161
+ # Supported categories for load_devices gating
162
+ SUPPORTED_CATEGORIES: ClassVar[list[str]] = [
163
+ DeviceCatagories.COMMON_DEVICE_CATEGORY.value,
164
+ DeviceCatagories.CAMERA_DEVICE_CATEGORY.value,
165
+ DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value,
166
+ DeviceCatagories.DOORBELL_DEVICE_CATEGORY.value,
167
+ DeviceCatagories.BASE_STATION_DEVICE_CATEGORY.value,
168
+ DeviceCatagories.CAT_EYE_CATEGORY.value,
169
+ DeviceCatagories.LIGHTING.value,
170
+ ]
171
+
90
172
  def __init__(
91
173
  self,
92
174
  account: str | None = None,
@@ -102,20 +184,24 @@ class EzvizClient:
102
184
  ) # Ezviz API sends md5 of password
103
185
  self._session = requests.session()
104
186
  self._session.headers.update(REQUEST_HEADER)
105
- self._session.headers["sessionId"] = token["session_id"] if token else None
106
- self._token = token or {
107
- "session_id": None,
108
- "rf_session_id": None,
109
- "username": None,
110
- "api_url": url,
111
- }
187
+ if token and token.get("session_id"):
188
+ self._session.headers["sessionId"] = str(token["session_id"]) # ensure str
189
+ self._token: ClientToken = cast(
190
+ ClientToken,
191
+ token or {
192
+ "session_id": None,
193
+ "rf_session_id": None,
194
+ "username": None,
195
+ "api_url": url,
196
+ },
197
+ )
112
198
  self._timeout = timeout
113
199
  self._cameras: dict[str, Any] = {}
114
200
  self._light_bulbs: dict[str, Any] = {}
201
+ self.mqtt_client: MQTTClient | None = None
115
202
 
116
203
  def _login(self, smscode: int | None = None) -> dict[Any, Any]:
117
204
  """Login to Ezviz API."""
118
-
119
205
  # Region code to url.
120
206
  if len(self._token["api_url"].split(".")) == 1:
121
207
  self._token["api_url"] = "apii" + self._token["api_url"] + ".ezvizlife.com"
@@ -170,12 +256,16 @@ class EzvizClient:
170
256
 
171
257
  self._token["service_urls"] = self.get_service_urls()
172
258
 
173
- return self._token
259
+ return cast(dict[Any, Any], self._token)
174
260
 
175
261
  if json_result["meta"]["code"] == 1100:
176
262
  self._token["api_url"] = json_result["loginArea"]["apiDomain"]
177
- _LOGGER.warning("Region incorrect!")
178
- _LOGGER.warning("Your region url: %s", self._token["api_url"])
263
+ _LOGGER.warning(
264
+ "region_incorrect: serial=%s code=%s msg=%s",
265
+ "unknown",
266
+ 1100,
267
+ self._token["api_url"],
268
+ )
179
269
  return self.login()
180
270
 
181
271
  if json_result["meta"]["code"] == 1012:
@@ -198,75 +288,230 @@ class EzvizClient:
198
288
 
199
289
  raise PyEzvizError(f"Login error: {json_result['meta']}")
200
290
 
201
- def send_mfa_code(self) -> bool:
202
- """Send verification code."""
291
+ # ---- Internal HTTP helpers -------------------------------------------------
292
+
293
+ def _http_request(
294
+ self,
295
+ method: str,
296
+ url: str,
297
+ *,
298
+ params: dict | None = None,
299
+ data: dict | str | None = None,
300
+ json_body: dict | None = None,
301
+ retry_401: bool = True,
302
+ max_retries: int = 0,
303
+ ) -> requests.Response:
304
+ """Perform an HTTP request with optional 401 retry via re-login.
305
+
306
+ Centralizes the common 401→login→retry pattern without altering
307
+ individual endpoint behavior. Returns the Response for the caller to
308
+ parse and validate according to its API contract.
309
+ """
203
310
  try:
204
- req = self._session.post(
205
- url=f"https://{self._token['api_url']}{API_ENDPOINT_SEND_CODE}",
206
- data={
207
- "from": self.account,
208
- "bizType": "TERMINAL_BIND",
209
- },
311
+ req = self._session.request(
312
+ method=method,
313
+ url=url,
314
+ params=params,
315
+ data=data,
316
+ json=json_body,
210
317
  timeout=self._timeout,
211
318
  )
212
-
213
319
  req.raise_for_status()
214
-
215
320
  except requests.HTTPError as err:
321
+ if retry_401 and err.response is not None and err.response.status_code == 401:
322
+ if max_retries >= MAX_RETRIES:
323
+ raise HTTPError from err
324
+ # Re-login and retry once
325
+ self.login()
326
+ return self._http_request(
327
+ method,
328
+ url,
329
+ params=params,
330
+ data=data,
331
+ json_body=json_body,
332
+ retry_401=retry_401,
333
+ max_retries=max_retries + 1,
334
+ )
216
335
  raise HTTPError from err
336
+ else:
337
+ return req
217
338
 
339
+ @staticmethod
340
+ def _parse_json(resp: requests.Response) -> dict:
341
+ """Parse JSON or raise a friendly error."""
218
342
  try:
219
- json_output = req.json()
220
-
343
+ return cast(dict, resp.json())
221
344
  except ValueError as err:
222
345
  raise PyEzvizError(
223
- "Impossible to decode response: "
224
- + str(err)
225
- + "\nResponse was: "
226
- + str(req.text)
346
+ "Impossible to decode response: " + str(err) + "\nResponse was: " + str(resp.text)
227
347
  ) from err
228
348
 
229
- if json_output["meta"]["code"] != 200:
230
- raise PyEzvizError(f"Could not request MFA code: Got {json_output})")
349
+ @staticmethod
350
+ def _is_ok(payload: dict) -> bool:
351
+ """Return True if payload indicates success for both API styles."""
352
+ meta = payload.get("meta")
353
+ if isinstance(meta, dict) and meta.get("code") == 200:
354
+ return True
355
+ rc = payload.get("resultCode")
356
+ return rc in (0, "0")
357
+
358
+ @staticmethod
359
+ def _meta_code(payload: dict) -> int | None:
360
+ """Safely extract meta.code as an int, or None if missing/invalid."""
361
+ code = (payload.get("meta") or {}).get("code")
362
+ if isinstance(code, (int, str)):
363
+ try:
364
+ return int(code)
365
+ except (TypeError, ValueError):
366
+ return None
367
+ return None
231
368
 
232
- return True
369
+ @staticmethod
370
+ def _meta_ok(payload: dict) -> bool:
371
+ """Return True if meta.code equals 200."""
372
+ return EzvizClient._meta_code(payload) == 200
233
373
 
234
- def get_service_urls(self) -> Any:
235
- """Get Ezviz service urls."""
374
+ @staticmethod
375
+ def _response_code(payload: dict) -> int | str | None:
376
+ """Return a best-effort code from a response for logging.
236
377
 
237
- if not self._token["session_id"]:
238
- raise PyEzvizError("No Login token present!")
378
+ Prefers modern ``meta.code`` if present; falls back to legacy
379
+ ``resultCode`` or a top-level ``status`` field when available.
380
+ Returns None if no code-like field is found.
381
+ """
382
+ # Prefer modern meta.code
383
+ mc = EzvizClient._meta_code(payload)
384
+ if mc is not None:
385
+ return mc
386
+ if "resultCode" in payload:
387
+ return payload.get("resultCode")
388
+ if "status" in payload:
389
+ return payload.get("status")
390
+ return None
239
391
 
240
- try:
241
- req = self._session.get(
242
- url=f"https://{self._token['api_url']}{API_ENDPOINT_SERVER_INFO}",
243
- timeout=self._timeout,
244
- )
245
- req.raise_for_status()
392
+ def _ensure_ok(self, payload: dict, message: str) -> None:
393
+ """Raise PyEzvizError with context if response is not OK.
246
394
 
247
- except requests.ConnectionError as err:
248
- raise InvalidURL("A Invalid URL or Proxy error occurred") from err
395
+ Accepts both API styles: new (meta.code == 200) and legacy (resultCode == 0).
396
+ """
397
+ if not self._is_ok(payload):
398
+ raise PyEzvizError(f"{message}: Got {payload})")
399
+
400
+ def _send_prepared(
401
+ self,
402
+ prepared: requests.PreparedRequest,
403
+ *,
404
+ retry_401: bool = True,
405
+ max_retries: int = 0,
406
+ ) -> requests.Response:
407
+ """Send a prepared request with optional 401 retry.
249
408
 
409
+ Useful for endpoints requiring special URL encoding or manual preparation.
410
+ """
411
+ try:
412
+ req = self._session.send(request=prepared, timeout=self._timeout)
413
+ req.raise_for_status()
250
414
  except requests.HTTPError as err:
415
+ if retry_401 and err.response is not None and err.response.status_code == 401:
416
+ if max_retries >= MAX_RETRIES:
417
+ raise HTTPError from err
418
+ self.login()
419
+ return self._send_prepared(prepared, retry_401=retry_401, max_retries=max_retries + 1)
251
420
  raise HTTPError from err
421
+ return req
252
422
 
253
- try:
254
- json_output = req.json()
423
+ # ---- Small helpers --------------------------------------------------------------
255
424
 
256
- except ValueError as err:
257
- raise PyEzvizError(
258
- "Impossible to decode response: "
259
- + str(err)
260
- + "\nResponse was: "
261
- + str(req.text)
262
- ) from err
425
+ def _url(self, path: str) -> str:
426
+ """Build a full API URL for the given path."""
427
+ return f"https://{self._token['api_url']}{path}"
263
428
 
264
- if json_output["meta"]["code"] != 200:
265
- raise PyEzvizError(f"Error getting Service URLs: {json_output}")
429
+ def _request_json(
430
+ self,
431
+ method: str,
432
+ path: str,
433
+ *,
434
+ params: dict | None = None,
435
+ data: dict | str | None = None,
436
+ json_body: dict | None = None,
437
+ retry_401: bool = True,
438
+ max_retries: int = 0,
439
+ ) -> dict:
440
+ """Perform request and parse JSON in one step."""
441
+ resp = self._http_request(
442
+ method,
443
+ self._url(path),
444
+ params=params,
445
+ data=data,
446
+ json_body=json_body,
447
+ retry_401=retry_401,
448
+ max_retries=max_retries,
449
+ )
450
+ return self._parse_json(resp)
451
+
452
+ def _retry_json(
453
+ self,
454
+ producer: Callable[[], dict],
455
+ *,
456
+ attempts: int,
457
+ should_retry: Callable[[dict], bool],
458
+ log: str,
459
+ serial: str | None = None,
460
+ ) -> dict:
461
+ """Run a JSON-producing callable with retry policy.
266
462
 
267
- service_urls = json_output["systemConfigInfo"]
268
- service_urls["sysConf"] = service_urls["sysConf"].split("|")
463
+ Calls ``producer`` up to ``attempts + 1`` times. After each call, the
464
+ result is passed to ``should_retry``; if it returns True and attempts
465
+ remain, a retry is performed and a concise warning is logged. If it
466
+ returns False, the payload is returned to the caller.
269
467
 
468
+ Raises:
469
+ PyEzvizError: If retries are exhausted without a successful payload.
470
+ """
471
+ total = max(0, attempts)
472
+ for attempt in range(total + 1):
473
+ payload = producer()
474
+ if not should_retry(payload):
475
+ return payload
476
+ if attempt < total:
477
+ # Prefer modern meta.code; fall back to legacy resultCode
478
+ code = self._response_code(payload)
479
+ _LOGGER.warning(
480
+ "http_retry: serial=%s code=%s msg=%s",
481
+ serial or "unknown",
482
+ code,
483
+ log,
484
+ )
485
+ raise PyEzvizError(f"{log}: exceeded retries")
486
+
487
+ def send_mfa_code(self) -> bool:
488
+ """Send verification code."""
489
+ json_output = self._request_json(
490
+ "POST",
491
+ API_ENDPOINT_SEND_CODE,
492
+ data={"from": self.account, "bizType": "TERMINAL_BIND"},
493
+ retry_401=False,
494
+ )
495
+
496
+ if not self._meta_ok(json_output):
497
+ raise PyEzvizError(f"Could not request MFA code: Got {json_output})")
498
+
499
+ return True
500
+
501
+ def get_service_urls(self) -> Any:
502
+ """Get Ezviz service urls."""
503
+ if not self._token["session_id"]:
504
+ raise PyEzvizError("No Login token present!")
505
+
506
+ try:
507
+ json_output = self._request_json("GET", API_ENDPOINT_SERVER_INFO)
508
+ except requests.ConnectionError as err: # pragma: no cover - keep behavior
509
+ raise InvalidURL("A Invalid URL or Proxy error occurred") from err
510
+ if not self._meta_ok(json_output):
511
+ raise PyEzvizError(f"Error getting Service URLs: {json_output}")
512
+
513
+ service_urls = json_output.get("systemConfigInfo", {})
514
+ service_urls["sysConf"] = str(service_urls.get("sysConf", "")).split("|")
270
515
  return service_urls
271
516
 
272
517
  def _api_get_pagelist(
@@ -279,7 +524,6 @@ class EzvizClient:
279
524
  max_retries: int = 0,
280
525
  ) -> Any:
281
526
  """Get data from pagelist API."""
282
-
283
527
  if max_retries > MAX_RETRIES:
284
528
  raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
285
529
 
@@ -293,43 +537,21 @@ class EzvizClient:
293
537
  "filter": page_filter,
294
538
  }
295
539
 
296
- try:
297
- req = self._session.get(
298
- url=f"https://{self._token['api_url']}{API_ENDPOINT_PAGELIST}",
299
- params=params,
300
- timeout=self._timeout,
301
- )
302
-
303
- req.raise_for_status()
304
-
305
- except requests.HTTPError as err:
306
- if err.response.status_code == 401:
307
- # session is wrong, need to relogin
308
- self.login()
309
- return self._api_get_pagelist(
310
- page_filter, json_key, group_id, limit, offset, max_retries + 1
311
- )
312
-
313
- raise HTTPError from err
314
-
315
- try:
316
- json_output = req.json()
317
-
318
- except ValueError as err:
319
- raise PyEzvizError(
320
- "Impossible to decode response: "
321
- + str(err)
322
- + "\nResponse was: "
323
- + str(req.text)
324
- ) from err
325
-
326
- if json_output["meta"]["code"] != 200:
327
- # session is wrong, need to relogin
540
+ json_output = self._request_json(
541
+ "GET",
542
+ API_ENDPOINT_PAGELIST,
543
+ params=params,
544
+ retry_401=True,
545
+ max_retries=max_retries,
546
+ )
547
+ if self._meta_code(json_output) != 200:
548
+ # session is wrong, need to relogin and retry
328
549
  self.login()
329
550
  _LOGGER.warning(
330
- "Could not get pagelist, relogging (max retries: %s), got: %s",
331
- str(max_retries),
332
- json_output,
551
+ "http_retry: serial=%s code=%s msg=%s",
552
+ "unknown",
553
+ self._meta_code(json_output),
554
+ "pagelist_relogin",
333
555
  )
334
556
  return self._api_get_pagelist(
335
557
  page_filter, json_key, group_id, limit, offset, max_retries + 1
@@ -352,9 +574,6 @@ class EzvizClient:
352
574
 
353
575
  def get_alarminfo(self, serial: str, limit: int = 1, max_retries: int = 0) -> dict:
354
576
  """Get data from alarm info API for camera serial."""
355
- if max_retries > MAX_RETRIES:
356
- raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
357
-
358
577
  params: dict[str, int | str] = {
359
578
  "deviceSerials": serial,
360
579
  "queryType": -1,
@@ -362,44 +581,21 @@ class EzvizClient:
362
581
  "stype": -1,
363
582
  }
364
583
 
365
- try:
366
- req = self._session.get(
367
- url=f"https://{self._token['api_url']}{API_ENDPOINT_ALARMINFO_GET}",
584
+ json_output = self._retry_json(
585
+ lambda: self._request_json(
586
+ "GET",
587
+ API_ENDPOINT_ALARMINFO_GET,
368
588
  params=params,
369
- timeout=self._timeout,
370
- )
371
-
372
- req.raise_for_status()
373
-
374
- except requests.HTTPError as err:
375
- if err.response.status_code == 401:
376
- # session is wrong, need to relogin
377
- self.login()
378
- return self.get_alarminfo(serial, limit, max_retries + 1)
379
-
380
- raise HTTPError from err
381
-
382
- try:
383
- json_output: dict = req.json()
384
-
385
- except ValueError as err:
386
- raise PyEzvizError(
387
- "Impossible to decode response: "
388
- + str(err)
389
- + "\nResponse was: "
390
- + str(req.text)
391
- ) from err
392
-
393
- if json_output["meta"]["code"] != 200:
394
- if json_output["meta"]["code"] == 500:
395
- _LOGGER.debug(
396
- "Retry getting alarm info, server returned busy: %s",
397
- json_output,
398
- )
399
- return self.get_alarminfo(serial, limit, max_retries + 1)
400
-
589
+ retry_401=True,
590
+ max_retries=0,
591
+ ),
592
+ attempts=max_retries,
593
+ should_retry=lambda p: self._meta_code(p) == 500,
594
+ log="alarm_info_server_busy",
595
+ serial=serial,
596
+ )
597
+ if self._meta_code(json_output) != 200:
401
598
  raise PyEzvizError(f"Could not get data from alarm api: Got {json_output})")
402
-
403
599
  return json_output
404
600
 
405
601
  def get_device_messages_list(
@@ -417,7 +613,7 @@ class EzvizClient:
417
613
  raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
418
614
 
419
615
  params: dict[str, int | str | None] = {
420
- "serials:": serials,
616
+ "serials": serials,
421
617
  "stype": s_type,
422
618
  "limit": limit,
423
619
  "date": date,
@@ -425,41 +621,14 @@ class EzvizClient:
425
621
  "tags": tags,
426
622
  }
427
623
 
428
- try:
429
- req = self._session.get(
430
- url=f"https://{self._token['api_url']}{API_ENDPOINT_UNIFIEDMSG_LIST_GET}",
431
- params=params,
432
- timeout=self._timeout,
433
- )
434
-
435
- req.raise_for_status()
436
-
437
- except requests.HTTPError as err:
438
- if err.response.status_code == 401:
439
- # session is wrong, need to relogin
440
- self.login()
441
- return self.get_device_messages_list(
442
- serials, s_type, limit, date, end_time, tags, max_retries + 1
443
- )
444
-
445
- raise HTTPError from err
446
-
447
- try:
448
- json_output: dict = req.json()
449
-
450
- except ValueError as err:
451
- raise PyEzvizError(
452
- "Impossible to decode response: "
453
- + str(err)
454
- + "\nResponse was: "
455
- + str(req.text)
456
- ) from err
457
-
458
- if json_output["meta"]["code"] != 200:
459
- raise PyEzvizError(
460
- f"Could not get unified message list: Got {json_output})"
461
- )
462
-
624
+ json_output = self._request_json(
625
+ "GET",
626
+ API_ENDPOINT_UNIFIEDMSG_LIST_GET,
627
+ params=params,
628
+ retry_401=True,
629
+ max_retries=max_retries,
630
+ )
631
+ self._ensure_ok(json_output, "Could not get unified message list")
463
632
  return json_output
464
633
 
465
634
  def switch_status(
@@ -473,40 +642,15 @@ class EzvizClient:
473
642
  """Camera features are represented as switches. Switch them on or off."""
474
643
  if max_retries > MAX_RETRIES:
475
644
  raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
476
-
477
- try:
478
- req = self._session.put(
479
- url=f"https://{self._token['api_url']}{API_ENDPOINT_DEVICES}{serial}/{channel_no}/{enable}/{status_type}{API_ENDPOINT_SWITCH_STATUS}",
480
- timeout=self._timeout,
481
- )
482
-
483
- req.raise_for_status()
484
-
485
- except requests.HTTPError as err:
486
- if err.response.status_code == 401:
487
- # session is wrong, need to relogin
488
- self.login()
489
- return self.switch_status(serial, status_type, enable, max_retries + 1)
490
-
491
- raise HTTPError from err
492
-
493
- try:
494
- json_output = req.json()
495
-
496
- except ValueError as err:
497
- raise PyEzvizError(
498
- "Impossible to decode response: "
499
- + str(err)
500
- + "\nResponse was: "
501
- + str(req.text)
502
- ) from err
503
-
504
- if json_output["meta"]["code"] != 200:
505
- raise PyEzvizError(f"Could not set the switch: Got {json_output})")
506
-
645
+ json_output = self._request_json(
646
+ "PUT",
647
+ f"{API_ENDPOINT_DEVICES}{serial}/{channel_no}/{enable}/{status_type}{API_ENDPOINT_SWITCH_STATUS}",
648
+ retry_401=True,
649
+ max_retries=max_retries,
650
+ )
651
+ self._ensure_ok(json_output, "Could not set the switch")
507
652
  if self._cameras.get(serial):
508
653
  self._cameras[serial]["switches"][status_type] = bool(enable)
509
-
510
654
  return True
511
655
 
512
656
  def switch_status_other(
@@ -524,43 +668,14 @@ class EzvizClient:
524
668
  if max_retries > MAX_RETRIES:
525
669
  raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
526
670
 
527
- try:
528
- req = self._session.put(
529
- url=f"https://{self._token['api_url']}{API_ENDPOINT_DEVICES}{serial}{API_ENDPOINT_SWITCH_OTHER}",
530
- timeout=self._timeout,
531
- params={
532
- "channelNo": channel_number,
533
- "enable": enable,
534
- "switchType": status_type,
535
- },
536
- )
537
-
538
- req.raise_for_status()
539
-
540
- except requests.HTTPError as err:
541
- if err.response.status_code == 401:
542
- # session is wrong, need to relogin
543
- self.login()
544
- return self.switch_status_other(
545
- serial, status_type, enable, channel_number, max_retries + 1
546
- )
547
-
548
- raise HTTPError from err
549
-
550
- try:
551
- json_output = req.json()
552
-
553
- except ValueError as err:
554
- raise PyEzvizError(
555
- "Impossible to decode response: "
556
- + str(err)
557
- + "\nResponse was: "
558
- + str(req.text)
559
- ) from err
560
-
561
- if json_output["meta"]["code"] != 200:
562
- raise PyEzvizError(f"Could not set the switch: Got {json_output})")
563
-
671
+ json_output = self._request_json(
672
+ "PUT",
673
+ f"{API_ENDPOINT_DEVICES}{serial}{API_ENDPOINT_SWITCH_OTHER}",
674
+ params={"channelNo": channel_number, "enable": enable, "switchType": status_type},
675
+ retry_401=True,
676
+ max_retries=max_retries,
677
+ )
678
+ self._ensure_ok(json_output, "Could not set the switch")
564
679
  return True
565
680
 
566
681
  def set_camera_defence(
@@ -573,57 +688,22 @@ class EzvizClient:
573
688
  max_retries: int = 0,
574
689
  ) -> bool:
575
690
  """Enable/Disable motion detection on camera."""
576
-
577
- if max_retries > MAX_RETRIES:
578
- raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
579
-
580
- try:
581
- req = self._session.put(
582
- url=f"https://{self._token['api_url']}{API_ENDPOINT_DEVICES}{serial}/{channel_no}{API_ENDPOINT_CHANGE_DEFENCE_STATUS}",
583
- timeout=self._timeout,
584
- data={
585
- "type": arm_type,
586
- "status": enable,
587
- "actor": actor,
588
- },
589
- )
590
-
591
- req.raise_for_status()
592
-
593
- except requests.HTTPError as err:
594
- if err.response.status_code == 401:
595
- # session is wrong, need to relogin
596
- self.login()
597
- return self.set_camera_defence(serial, enable, max_retries + 1)
598
-
599
- raise HTTPError from err
600
-
601
- try:
602
- json_output = req.json()
603
-
604
- except ValueError as err:
605
- raise PyEzvizError(
606
- "Impossible to decode response: "
607
- + str(err)
608
- + "\nResponse was: "
609
- + str(req.text)
610
- ) from err
611
-
612
- if json_output["meta"]["code"] != 200:
613
- if json_output["meta"]["code"] == 504:
614
- _LOGGER.warning(
615
- "Arm or disarm for camera %s timed out. Retrying %s of %s",
616
- serial,
617
- max_retries,
618
- MAX_RETRIES,
619
- )
620
- return self.set_camera_defence(serial, enable, max_retries + 1)
621
-
622
- raise PyEzvizError(
623
- f"Could not arm or disarm Camera {serial}: Got {json_output})"
624
- )
625
-
626
- return True
691
+ json_output = self._retry_json(
692
+ lambda: self._request_json(
693
+ "PUT",
694
+ f"{API_ENDPOINT_DEVICES}{serial}/{channel_no}{API_ENDPOINT_CHANGE_DEFENCE_STATUS}",
695
+ data={"type": arm_type, "status": enable, "actor": actor},
696
+ retry_401=True,
697
+ max_retries=0,
698
+ ),
699
+ attempts=max_retries,
700
+ should_retry=lambda p: self._meta_code(p) == 504,
701
+ log="arm_disarm_timeout",
702
+ serial=serial,
703
+ )
704
+ if self._meta_code(json_output) != 200:
705
+ raise PyEzvizError(f"Could not arm or disarm Camera {serial}: Got {json_output})")
706
+ return True
627
707
 
628
708
  def set_battery_camera_work_mode(self, serial: str, value: int) -> bool:
629
709
  """Set battery camera work mode."""
@@ -676,36 +756,9 @@ class EzvizClient:
676
756
  ).prepare()
677
757
  req_prep.url = full_url + "?" + params_str
678
758
 
679
- try:
680
- req = self._session.send(
681
- request=req_prep,
682
- timeout=self._timeout,
683
- )
684
-
685
- req.raise_for_status()
686
-
687
- except requests.HTTPError as err:
688
- if err.response.status_code == 401:
689
- # session is wrong, need to relogin
690
- self.login()
691
- return self.set_device_config_by_key(
692
- serial, value, key, max_retries + 1
693
- )
694
-
695
- raise HTTPError from err
696
-
697
- try:
698
- json_output = req.json()
699
-
700
- except ValueError as err:
701
- raise PyEzvizError(
702
- "Impossible to decode response: "
703
- + str(err)
704
- + "\nResponse was: "
705
- + str(req.text)
706
- ) from err
707
-
708
- if json_output["meta"]["code"] != 200:
759
+ req = self._send_prepared(req_prep, retry_401=True, max_retries=max_retries)
760
+ json_output = self._parse_json(req)
761
+ if not self._meta_ok(json_output):
709
762
  raise PyEzvizError(f"Could not set config key '${key}': Got {json_output})")
710
763
 
711
764
  return True
@@ -737,205 +790,69 @@ class EzvizClient:
737
790
  method="PUT", url=full_url, headers=headers, data=payload
738
791
  ).prepare()
739
792
 
740
- try:
741
- req = self._session.send(
742
- request=req_prep,
743
- timeout=self._timeout,
744
- )
745
-
746
- req.raise_for_status()
747
-
748
- except requests.HTTPError as err:
749
- if err.response.status_code == 401:
750
- # session is wrong, need to relogin
751
- self.login()
752
- return self.set_device_feature_by_key(
753
- serial, product_id, value, key, max_retries + 1
754
- )
755
-
756
- raise HTTPError from err
757
-
758
- try:
759
- json_output = req.json()
760
-
761
- except ValueError as err:
762
- raise PyEzvizError(
763
- "Impossible to decode response: "
764
- + str(err)
765
- + "\nResponse was: "
766
- + str(req.text)
767
- ) from err
768
-
769
- if json_output["meta"]["code"] != 200:
793
+ req = self._send_prepared(req_prep, retry_401=True, max_retries=max_retries)
794
+ json_output = self._parse_json(req)
795
+ if not self._meta_ok(json_output):
770
796
  raise PyEzvizError(
771
- f"Could not set iot-feature key '${key}': Got {json_output})"
797
+ f"Could not set iot-feature key '{key}': Got {json_output})"
772
798
  )
773
799
 
774
800
  return True
775
801
 
776
802
  def upgrade_device(self, serial: str, max_retries: int = 0) -> bool:
777
803
  """Upgrade device firmware."""
778
- if max_retries > MAX_RETRIES:
779
- raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
780
-
781
- try:
782
- req = self._session.put(
783
- url=f"https://{self._token['api_url']}{API_ENDPOINT_UPGRADE_DEVICE}{serial}/0/upgrade",
784
- timeout=self._timeout,
785
- )
786
-
787
- req.raise_for_status()
788
-
789
- except requests.HTTPError as err:
790
- if err.response.status_code == 401:
791
- # session is wrong, need to relogin
792
- self.login()
793
- return self.upgrade_device(serial, max_retries + 1)
794
-
795
- raise HTTPError from err
796
-
797
- try:
798
- json_output = req.json()
799
-
800
- except ValueError as err:
801
- raise PyEzvizError(
802
- "Impossible to decode response: "
803
- + str(err)
804
- + "\nResponse was: "
805
- + str(req.text)
806
- ) from err
807
-
808
- if json_output["meta"]["code"] != 200:
809
- raise PyEzvizError(
810
- f"Could not initiate firmware upgrade: Got {json_output})"
811
- )
812
-
804
+ json_output = self._request_json(
805
+ "PUT",
806
+ f"{API_ENDPOINT_UPGRADE_DEVICE}{serial}/0/upgrade",
807
+ retry_401=True,
808
+ max_retries=max_retries,
809
+ )
810
+ self._ensure_ok(json_output, "Could not initiate firmware upgrade")
813
811
  return True
814
812
 
815
813
  def get_storage_status(self, serial: str, max_retries: int = 0) -> Any:
816
814
  """Get device storage status."""
817
- if max_retries > MAX_RETRIES:
818
- raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
819
-
820
- try:
821
- req = self._session.post(
822
- url=f"https://{self._token['api_url']}{API_ENDPOINT_DEVICE_STORAGE_STATUS}",
815
+ json_output = self._retry_json(
816
+ lambda: self._request_json(
817
+ "POST",
818
+ API_ENDPOINT_DEVICE_STORAGE_STATUS,
823
819
  data={"subSerial": serial},
824
- timeout=self._timeout,
825
- )
826
-
827
- req.raise_for_status()
828
-
829
- except requests.HTTPError as err:
830
- if err.response.status_code == 401:
831
- # session is wrong, need to relogin
832
- self.login()
833
- return self.get_storage_status(serial, max_retries + 1)
834
-
835
- raise HTTPError from err
836
-
837
- try:
838
- json_output = req.json()
839
-
840
- except ValueError as err:
841
- raise PyEzvizError(
842
- "Impossible to decode response: "
843
- + str(err)
844
- + "\nResponse was: "
845
- + str(req.text)
846
- ) from err
847
-
848
- if json_output["resultCode"] != "0":
849
- if json_output["resultCode"] == "-1":
850
- _LOGGER.warning(
851
- "Can't get storage status from device %s, retrying %s of %s",
852
- serial,
853
- max_retries,
854
- MAX_RETRIES,
855
- )
856
- return self.get_storage_status(serial, max_retries + 1)
820
+ retry_401=True,
821
+ max_retries=0,
822
+ ),
823
+ attempts=max_retries,
824
+ should_retry=lambda p: str(p.get("resultCode")) == "-1",
825
+ log="storage_status_unreachable",
826
+ serial=serial,
827
+ )
828
+ if str(json_output.get("resultCode")) != "0":
857
829
  raise PyEzvizError(
858
830
  f"Could not get device storage status: Got {json_output})"
859
831
  )
860
-
861
- return json_output["storageStatus"]
832
+ return json_output.get("storageStatus")
862
833
 
863
834
  def sound_alarm(self, serial: str, enable: int = 1, max_retries: int = 0) -> bool:
864
835
  """Sound alarm on a device."""
865
- if max_retries > MAX_RETRIES:
866
- raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
867
-
868
- try:
869
- req = self._session.put(
870
- url=f"https://{self._token['api_url']}{API_ENDPOINT_DEVICES}{serial}/0{API_ENDPOINT_SWITCH_SOUND_ALARM}",
871
- data={
872
- "enable": enable,
873
- },
874
- timeout=self._timeout,
875
- )
876
-
877
- req.raise_for_status()
878
-
879
- except requests.HTTPError as err:
880
- if err.response.status_code == 401:
881
- # session is wrong, need to relogin
882
- self.login()
883
- return self.sound_alarm(serial, enable, max_retries + 1)
884
-
885
- raise HTTPError from err
886
-
887
- try:
888
- json_output = req.json()
889
-
890
- except ValueError as err:
891
- raise PyEzvizError(
892
- "Impossible to decode response: "
893
- + str(err)
894
- + "\nResponse was: "
895
- + str(req.text)
896
- ) from err
897
-
898
- if json_output["meta"]["code"] != 200:
899
- raise PyEzvizError(f"Could not set the alarm sound: Got {json_output})")
900
-
836
+ json_output = self._request_json(
837
+ "PUT",
838
+ f"{API_ENDPOINT_DEVICES}{serial}/0{API_ENDPOINT_SWITCH_SOUND_ALARM}",
839
+ data={"enable": enable},
840
+ retry_401=True,
841
+ max_retries=max_retries,
842
+ )
843
+ self._ensure_ok(json_output, "Could not set the alarm sound")
901
844
  return True
902
845
 
903
846
  def get_user_id(self, max_retries: int = 0) -> Any:
904
847
  """Get Ezviz userid, used by restricted api endpoints."""
905
-
906
- if max_retries > MAX_RETRIES:
907
- raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
908
-
909
- try:
910
- req = self._session.get(
911
- url=f"https://{self._token['api_url']}{API_ENDPOINT_USER_ID}",
912
- timeout=self._timeout,
913
- )
914
- req.raise_for_status()
915
-
916
- except requests.HTTPError as err:
917
- if err.response.status_code == 401:
918
- # session is wrong, need to relogin
919
- self.login()
920
- return self.get_user_id(max_retries + 1)
921
-
922
- raise HTTPError from err
923
-
924
- try:
925
- json_output = req.json()
926
-
927
- except ValueError as err:
928
- raise PyEzvizError(
929
- "Impossible to decode response: "
930
- + str(err)
931
- + "\nResponse was: "
932
- + str(req.text)
933
- ) from err
934
-
935
- if json_output["meta"]["code"] != 200:
936
- raise PyEzvizError(f"Could get user id, Got: {json_output})")
937
-
938
- return json_output["deviceTokenInfo"]
848
+ json_output = self._request_json(
849
+ "GET",
850
+ API_ENDPOINT_USER_ID,
851
+ retry_401=True,
852
+ max_retries=max_retries,
853
+ )
854
+ self._ensure_ok(json_output, "Could not get user id")
855
+ return json_output.get("deviceTokenInfo")
939
856
 
940
857
  def set_video_enc(
941
858
  self,
@@ -956,51 +873,22 @@ class EzvizClient:
956
873
  if new_password and not enable == 2:
957
874
  raise PyEzvizError("New password is only required when changing password.")
958
875
 
959
- try:
960
- req = self._session.put(
961
- url=f"https://{self._token['api_url']}{API_ENDPOINT_DEVICES}{API_ENDPOINT_VIDEO_ENCRYPT}",
962
- data={
963
- "deviceSerial": serial,
964
- "isEncrypt": enable, # 1 = enable, 0 = disable, 2 = change password
965
- "oldPassword": old_password, # required if changing.
966
- "password": new_password,
967
- "featureCode": FEATURE_CODE,
968
- "validateCode": camera_verification_code,
969
- "msgType": -1,
970
- },
971
- timeout=self._timeout,
972
- )
973
-
974
- req.raise_for_status()
975
-
976
- except requests.HTTPError as err:
977
- if err.response.status_code == 401:
978
- # session is wrong, need to relogin
979
- self.login()
980
- return self.set_video_enc(
981
- serial,
982
- enable,
983
- camera_verification_code,
984
- new_password,
985
- old_password,
986
- max_retries + 1,
987
- )
988
-
989
- raise HTTPError from err
990
-
991
- try:
992
- json_output = req.json()
993
-
994
- except ValueError as err:
995
- raise PyEzvizError(
996
- "Impossible to decode response: "
997
- + str(err)
998
- + "\nResponse was: "
999
- + str(req.text)
1000
- ) from err
1001
-
1002
- if json_output["meta"]["code"] != 200:
1003
- raise PyEzvizError(f"Could not set video encryption: Got {json_output})")
876
+ json_output = self._request_json(
877
+ "PUT",
878
+ f"{API_ENDPOINT_DEVICES}{API_ENDPOINT_VIDEO_ENCRYPT}",
879
+ data={
880
+ "deviceSerial": serial,
881
+ "isEncrypt": enable,
882
+ "oldPassword": old_password,
883
+ "password": new_password,
884
+ "featureCode": FEATURE_CODE,
885
+ "validateCode": camera_verification_code,
886
+ "msgType": -1,
887
+ },
888
+ retry_401=True,
889
+ max_retries=max_retries,
890
+ )
891
+ self._ensure_ok(json_output, "Could not set video encryption")
1004
892
 
1005
893
  return True
1006
894
 
@@ -1015,54 +903,21 @@ class EzvizClient:
1015
903
  if max_retries > MAX_RETRIES:
1016
904
  raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
1017
905
 
1018
- try:
1019
- req = self._session.post(
1020
- url=f"https://{self._token['api_url']}{API_ENDPOINT_DEVICE_SYS_OPERATION}{serial}",
1021
- data={
1022
- "oper": operation,
1023
- "deviceSerial": serial,
1024
- "delay": delay,
1025
- },
1026
- timeout=self._timeout,
1027
- )
1028
-
1029
- req.raise_for_status()
1030
-
1031
- except requests.HTTPError as err:
1032
- if err.response.status_code == 401:
1033
- # session is wrong, need to relogin
1034
- self.login()
1035
- return self.reboot_camera(
1036
- serial,
1037
- delay,
1038
- operation,
1039
- max_retries + 1,
1040
- )
1041
-
1042
- raise HTTPError from err
1043
-
1044
- try:
1045
- json_output = req.json()
1046
-
1047
- except ValueError as err:
1048
- raise PyEzvizError(
1049
- "Impossible to decode response: "
1050
- + str(err)
1051
- + "\nResponse was: "
1052
- + str(req.text)
1053
- ) from err
1054
-
1055
- if json_output["resultCode"] != "0":
1056
- if json_output["resultCode"] == "-1":
1057
- _LOGGER.warning(
1058
- "Unable to reboot camera, camera %s is unreachable, retrying %s of %s",
1059
- serial,
1060
- max_retries,
1061
- MAX_RETRIES,
1062
- )
1063
- return self.reboot_camera(serial, delay, operation, max_retries + 1)
906
+ json_output = self._retry_json(
907
+ lambda: self._request_json(
908
+ "POST",
909
+ f"{API_ENDPOINT_DEVICE_SYS_OPERATION}{serial}",
910
+ data={"oper": operation, "deviceSerial": serial, "delay": delay},
911
+ retry_401=True,
912
+ max_retries=0,
913
+ ),
914
+ attempts=max_retries,
915
+ should_retry=lambda p: str(p.get("resultCode")) == "-1",
916
+ log="reboot_unreachable",
917
+ serial=serial,
918
+ )
919
+ if str(json_output.get("resultCode")) not in ("0", 0):
1064
920
  raise PyEzvizError(f"Could not reboot device {json_output})")
1065
-
1066
921
  return True
1067
922
 
1068
923
  def set_offline_notification(
@@ -1076,100 +931,43 @@ class EzvizClient:
1076
931
  if max_retries > MAX_RETRIES:
1077
932
  raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
1078
933
 
1079
- try:
1080
- req = self._session.post(
1081
- url=f"https://{self._token['api_url']}{API_ENDPOINT_OFFLINE_NOTIFY}",
1082
- data={
1083
- "reqType": req_type,
1084
- "serial": serial,
1085
- "status": enable,
1086
- },
1087
- timeout=self._timeout,
934
+ attempts = max(0, max_retries)
935
+ for attempt in range(attempts + 1):
936
+ json_output = self._request_json(
937
+ "POST",
938
+ API_ENDPOINT_OFFLINE_NOTIFY,
939
+ data={"reqType": req_type, "serial": serial, "status": enable},
940
+ retry_401=True,
941
+ max_retries=0,
1088
942
  )
1089
-
1090
- req.raise_for_status()
1091
-
1092
- except requests.HTTPError as err:
1093
- if err.response.status_code == 401:
1094
- # session is wrong, need to relogin
1095
- self.login()
1096
- return self.set_offline_notification(
1097
- serial,
1098
- enable,
1099
- req_type,
1100
- max_retries + 1,
1101
- )
1102
-
1103
- raise HTTPError from err
1104
-
1105
- try:
1106
- json_output = req.json()
1107
-
1108
- except ValueError as err:
1109
- raise PyEzvizError(
1110
- "Impossible to decode response: "
1111
- + str(err)
1112
- + "\nResponse was: "
1113
- + str(req.text)
1114
- ) from err
1115
-
1116
- if json_output["resultCode"] != "0":
1117
- if json_output["resultCode"] == "-1":
943
+ result = str(json_output.get("resultCode"))
944
+ if result == "0":
945
+ return True
946
+ if result == "-1" and attempt < attempts:
1118
947
  _LOGGER.warning(
1119
- "Unable to set offline notification, camera %s is unreachable, retrying %s of %s",
948
+ "Unable to set offline notification, camera %s is unreachable, retrying %s/%s",
1120
949
  serial,
1121
- max_retries,
1122
- MAX_RETRIES,
1123
- )
1124
- return self.set_offline_notification(
1125
- serial, enable, req_type, max_retries + 1
950
+ attempt + 1,
951
+ attempts,
1126
952
  )
953
+ continue
1127
954
  raise PyEzvizError(f"Could not set offline notification {json_output})")
955
+ raise PyEzvizError("Could not set offline notification: exceeded retries")
1128
956
 
1129
- return True
1130
-
1131
- def get_group_defence_mode(self, max_retries: int = 0) -> Any:
1132
- """Get group arm status. The alarm arm/disarm concept on 1st page of app."""
1133
-
1134
- if max_retries > MAX_RETRIES:
1135
- raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
1136
-
1137
- try:
1138
- req = self._session.get(
1139
- url=f"https://{self._token['api_url']}{API_ENDPOINT_GROUP_DEFENCE_MODE}",
1140
- params={
1141
- "groupId": -1,
1142
- },
1143
- timeout=self._timeout,
1144
- )
1145
-
1146
- req.raise_for_status()
1147
-
1148
- except requests.HTTPError as err:
1149
- if err.response.status_code == 401:
1150
- # session is wrong, need to relogin
1151
- self.login()
1152
- return self.get_group_defence_mode(max_retries + 1)
1153
-
1154
- raise HTTPError from err
1155
-
1156
- try:
1157
- json_output = req.json()
1158
-
1159
- except ValueError as err:
1160
- raise PyEzvizError(
1161
- "Impossible to decode response: "
1162
- + str(err)
1163
- + "\nResponse was: "
1164
- + str(req.text)
1165
- ) from err
1166
-
1167
- if json_output["meta"]["code"] != 200:
1168
- raise PyEzvizError(
1169
- f"Could not get group defence status: Got {json_output})"
1170
- )
957
+ def get_group_defence_mode(self, max_retries: int = 0) -> Any:
958
+ """Get group arm status. The alarm arm/disarm concept on 1st page of app."""
959
+ if max_retries > MAX_RETRIES:
960
+ raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
1171
961
 
1172
- return json_output["mode"]
962
+ json_output = self._request_json(
963
+ "GET",
964
+ API_ENDPOINT_GROUP_DEFENCE_MODE,
965
+ params={"groupId": -1},
966
+ retry_401=True,
967
+ max_retries=max_retries,
968
+ )
969
+ self._ensure_ok(json_output, "Could not get group defence status")
970
+ return json_output.get("mode")
1173
971
 
1174
972
  # Not tested
1175
973
  def cancel_alarm_device(self, serial: str, max_retries: int = 0) -> bool:
@@ -1177,92 +975,85 @@ class EzvizClient:
1177
975
  if max_retries > MAX_RETRIES:
1178
976
  raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
1179
977
 
1180
- try:
1181
- req = self._session.post(
1182
- url=f"https://{self._token['api_url']}{API_ENDPOINT_CANCEL_ALARM}",
1183
- data={"subSerial": serial},
1184
- timeout=self._timeout,
1185
- )
1186
-
1187
- req.raise_for_status()
1188
-
1189
- except requests.HTTPError as err:
1190
- if err.response.status_code == 401:
1191
- # session is wrong, need to relogin
1192
- self.login()
1193
- return self.sound_alarm(serial, max_retries + 1)
1194
-
1195
- raise HTTPError from err
1196
-
1197
- try:
1198
- json_output = req.json()
1199
-
1200
- except ValueError as err:
1201
- raise PyEzvizError(
1202
- "Impossible to decode response: "
1203
- + str(err)
1204
- + "\nResponse was: "
1205
- + str(req.text)
1206
- ) from err
978
+ json_output = self._request_json(
979
+ "POST",
980
+ API_ENDPOINT_CANCEL_ALARM,
981
+ data={"subSerial": serial},
982
+ retry_401=True,
983
+ max_retries=max_retries,
984
+ )
985
+ self._ensure_ok(json_output, "Could not cancel alarm siren")
986
+ return True
1207
987
 
1208
- if json_output["meta"]["code"] != 200:
1209
- raise PyEzvizError(f"Could not cancel alarm siren: Got {json_output})")
988
+ def load_devices(self, refresh: bool = True) -> dict[Any, Any]:
989
+ """Build status maps for cameras and light bulbs.
1210
990
 
1211
- return True
991
+ refresh: if True, camera.status() may perform network fetches (e.g. alarms).
992
+ Returns a combined mapping of serial -> status dict for both cameras and bulbs.
993
+ """
994
+ # Reset caches to reflect the current device roster
995
+ self._cameras.clear()
996
+ self._light_bulbs.clear()
1212
997
 
1213
- def load_devices(self) -> dict[Any, Any]:
1214
- """Load and return all cameras and light bulb objects."""
998
+ # Build lightweight records for clean gating/selection
999
+ records = cast(dict[str, EzvizDeviceRecord], self.get_device_records(None))
1000
+ supported_categories = self.SUPPORTED_CATEGORIES
1215
1001
 
1216
- devices = self.get_device_infos()
1217
- supported_categories = [
1218
- DeviceCatagories.COMMON_DEVICE_CATEGORY.value,
1219
- DeviceCatagories.CAMERA_DEVICE_CATEGORY.value,
1220
- DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value,
1221
- DeviceCatagories.DOORBELL_DEVICE_CATEGORY.value,
1222
- DeviceCatagories.BASE_STATION_DEVICE_CATEGORY.value,
1223
- DeviceCatagories.CAT_EYE_CATEGORY.value,
1224
- DeviceCatagories.LIGHTING.value,
1225
- ]
1226
-
1227
- for device, data in devices.items():
1228
- if data["deviceInfos"]["deviceCategory"] in supported_categories:
1002
+ for device, rec in records.items():
1003
+ if rec.device_category in supported_categories:
1229
1004
  # Add support for connected HikVision cameras
1230
1005
  if (
1231
- data["deviceInfos"]["deviceCategory"]
1232
- == DeviceCatagories.COMMON_DEVICE_CATEGORY.value
1233
- and not data["deviceInfos"]["hik"]
1006
+ rec.device_category == DeviceCatagories.COMMON_DEVICE_CATEGORY.value
1007
+ and not (rec.raw.get("deviceInfos") or {}).get("hik")
1234
1008
  ):
1235
1009
  continue
1236
1010
 
1237
- if (
1238
- data["deviceInfos"]["deviceCategory"]
1239
- == DeviceCatagories.LIGHTING.value
1240
- ):
1241
- # Create a light bulb object
1242
- self._light_bulbs[device] = EzvizLightBulb(
1243
- self, device, data
1244
- ).status()
1011
+ if rec.device_category == DeviceCatagories.LIGHTING.value:
1012
+ try:
1013
+ # Create a light bulb object
1014
+ self._light_bulbs[device] = EzvizLightBulb(
1015
+ self, device, dict(rec.raw)
1016
+ ).status()
1017
+ except (PyEzvizError, KeyError, TypeError, ValueError) as err: # pragma: no cover - defensive
1018
+ _LOGGER.warning(
1019
+ "load_device_failed: serial=%s code=%s msg=%s",
1020
+ device,
1021
+ "load_error",
1022
+ str(err),
1023
+ )
1245
1024
  else:
1246
- # Create camera object
1247
- self._cameras[device] = EzvizCamera(self, device, data).status()
1025
+ try:
1026
+ # Create camera object
1027
+ cam = EzvizCamera(self, device, dict(rec.raw))
1028
+ self._cameras[device] = cam.status(refresh=refresh)
1029
+ except (PyEzvizError, KeyError, TypeError, ValueError) as err: # pragma: no cover - defensive
1030
+ _LOGGER.warning(
1031
+ "load_device_failed: serial=%s code=%s msg=%s",
1032
+ device,
1033
+ "load_error",
1034
+ str(err),
1035
+ )
1248
1036
 
1249
1037
  return {**self._cameras, **self._light_bulbs}
1250
1038
 
1251
- def load_cameras(self) -> dict[Any, Any]:
1252
- """Load and return all cameras objects."""
1039
+ def load_cameras(self, refresh: bool = True) -> dict[Any, Any]:
1040
+ """Load and return all camera status mappings.
1253
1041
 
1254
- self.load_devices()
1042
+ refresh: pass-through to load_devices() to control network fetches.
1043
+ """
1044
+ self.load_devices(refresh=refresh)
1255
1045
  return self._cameras
1256
1046
 
1257
- def load_light_bulbs(self) -> dict[Any, Any]:
1258
- """Load light bulbs."""
1047
+ def load_light_bulbs(self, refresh: bool = True) -> dict[Any, Any]:
1048
+ """Load and return all light bulb status mappings.
1259
1049
 
1260
- self.load_devices()
1050
+ refresh: pass-through to load_devices().
1051
+ """
1052
+ self.load_devices(refresh=refresh)
1261
1053
  return self._light_bulbs
1262
1054
 
1263
1055
  def get_device_infos(self, serial: str | None = None) -> dict[Any, Any]:
1264
1056
  """Load all devices and build dict per device serial."""
1265
-
1266
1057
  devices = self._get_page_list()
1267
1058
  result: dict[str, Any] = {}
1268
1059
  _res_id = "NONE"
@@ -1312,7 +1103,20 @@ class EzvizClient:
1312
1103
  if not serial:
1313
1104
  return result
1314
1105
 
1315
- return result.get(serial, {})
1106
+ return cast(dict[Any, Any], result.get(serial, {}))
1107
+
1108
+ def get_device_records(
1109
+ self, serial: str | None = None
1110
+ ) -> dict[str, EzvizDeviceRecord] | EzvizDeviceRecord | dict[Any, Any]:
1111
+ """Return devices as EzvizDeviceRecord mapping (or single record).
1112
+
1113
+ Falls back to raw when a specific serial is requested but not found.
1114
+ """
1115
+ devices = self.get_device_infos()
1116
+ records = build_device_records_map(devices)
1117
+ if serial is None:
1118
+ return records
1119
+ return records.get(serial) or devices.get(serial, {})
1316
1120
 
1317
1121
  def ptz_control(
1318
1122
  self, command: str, serial: str, action: str, speed: int = 5
@@ -1323,37 +1127,26 @@ class EzvizClient:
1323
1127
  if action is None:
1324
1128
  raise PyEzvizError("Trying to call ptzControl without action")
1325
1129
 
1326
- try:
1327
- req = self._session.put(
1328
- url=f"https://{self._token['api_url']}{API_ENDPOINT_DEVICES}{serial}{API_ENDPOINT_PTZCONTROL}",
1329
- data={
1330
- "command": command,
1331
- "action": action,
1332
- "channelNo": 1,
1333
- "speed": speed,
1334
- "uuid": str(uuid4()),
1335
- "serial": serial,
1336
- },
1337
- timeout=self._timeout,
1338
- )
1339
-
1340
- req.raise_for_status()
1341
-
1342
- except requests.HTTPError as err:
1343
- raise HTTPError from err
1344
-
1345
- try:
1346
- json_output = req.json()
1347
-
1348
- except ValueError as err:
1349
- raise PyEzvizError(
1350
- "Impossible to decode response: "
1351
- + str(err)
1352
- + "\nResponse was: "
1353
- + str(req.text)
1354
- ) from err
1130
+ json_output = self._request_json(
1131
+ "PUT",
1132
+ f"{API_ENDPOINT_DEVICES}{serial}{API_ENDPOINT_PTZCONTROL}",
1133
+ data={
1134
+ "command": command,
1135
+ "action": action,
1136
+ "channelNo": 1,
1137
+ "speed": speed,
1138
+ "uuid": str(uuid4()),
1139
+ "serial": serial,
1140
+ },
1141
+ retry_401=False,
1142
+ )
1355
1143
 
1356
- _LOGGER.debug("PTZ Control: %s", json_output)
1144
+ _LOGGER.debug(
1145
+ "http_debug: serial=%s code=%s msg=%s",
1146
+ serial,
1147
+ self._meta_code(json_output),
1148
+ "ptz_control",
1149
+ )
1357
1150
 
1358
1151
  return True
1359
1152
 
@@ -1380,13 +1173,11 @@ class EzvizClient:
1380
1173
  "resultDes": str # Status message in chinese
1381
1174
  }
1382
1175
  """
1383
-
1384
- if max_retries > MAX_RETRIES:
1385
- raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
1386
-
1387
- try:
1388
- req = self._session.post(
1389
- url=f"https://{self._token['api_url']}{API_ENDPOINT_CAM_ENCRYPTKEY}",
1176
+ attempts = max(0, max_retries)
1177
+ for attempt in range(attempts + 1):
1178
+ json_output = self._request_json(
1179
+ "POST",
1180
+ API_ENDPOINT_CAM_ENCRYPTKEY,
1390
1181
  data={
1391
1182
  "checkcode": smscode,
1392
1183
  "serial": serial,
@@ -1396,50 +1187,30 @@ class EzvizClient:
1396
1187
  "featureCode": FEATURE_CODE,
1397
1188
  "sessionId": self._token["session_id"],
1398
1189
  },
1399
- timeout=self._timeout,
1190
+ retry_401=True,
1191
+ max_retries=0,
1400
1192
  )
1401
1193
 
1402
- req.raise_for_status()
1403
-
1404
- except requests.HTTPError as err:
1405
- if err.response.status_code == 401:
1406
- # session is wrong, need to relogin
1407
- self.login()
1408
- return self.get_cam_key(serial, smscode, max_retries + 1)
1409
-
1410
- raise HTTPError from err
1411
-
1412
- try:
1413
- json_output = req.json()
1414
-
1415
- except ValueError as err:
1416
- raise PyEzvizError(
1417
- "Impossible to decode response: "
1418
- + str(err)
1419
- + "\nResponse was: "
1420
- + str(req.text)
1421
- ) from err
1422
-
1423
- if json_output["resultCode"] == "20002":
1424
- raise EzvizAuthVerificationCode(f"MFA code required: Got {json_output})")
1425
-
1426
- if json_output["resultCode"] == 2009:
1427
- raise DeviceException(f"Device not reachable: Got {json_output})")
1428
-
1429
- if json_output["resultCode"] != "0":
1430
- if json_output["resultCode"] == "-1":
1194
+ code = str(json_output.get("resultCode"))
1195
+ if code == "20002":
1196
+ raise EzvizAuthVerificationCode(f"MFA code required: Got {json_output})")
1197
+ if code == "2009":
1198
+ raise DeviceException(f"Device not reachable: Got {json_output})")
1199
+ if code == "0":
1200
+ return json_output.get("encryptkey")
1201
+ if code == "-1" and attempt < attempts:
1431
1202
  _LOGGER.warning(
1432
- "Camera %s encryption key not found, retrying %s of %s",
1203
+ "http_retry: serial=%s code=%s msg=%s",
1433
1204
  serial,
1434
- max_retries,
1435
- MAX_RETRIES,
1205
+ code,
1206
+ "cam_key_not_found",
1436
1207
  )
1437
- return self.get_cam_key(serial, smscode, max_retries + 1)
1208
+ continue
1438
1209
  raise PyEzvizError(
1439
1210
  f"Could not get camera encryption key: Got {json_output})"
1440
1211
  )
1441
1212
 
1442
- return json_output["encryptkey"]
1213
+ raise PyEzvizError("Could not get camera encryption key: exceeded retries")
1443
1214
 
1444
1215
  def get_cam_auth_code(
1445
1216
  self,
@@ -1474,7 +1245,6 @@ class EzvizClient:
1474
1245
  }
1475
1246
  }
1476
1247
  """
1477
-
1478
1248
  if max_retries > MAX_RETRIES:
1479
1249
  raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
1480
1250
 
@@ -1484,43 +1254,21 @@ class EzvizClient:
1484
1254
  "senderType": sender_type,
1485
1255
  }
1486
1256
 
1487
- try:
1488
- req = self._session.get(
1489
- url=f"https://{self._token['api_url']}{API_ENDPOINT_CAM_AUTH_CODE}{serial}",
1490
- params=params,
1491
- timeout=self._timeout,
1492
- )
1493
-
1494
- req.raise_for_status()
1495
-
1496
- except requests.HTTPError as err:
1497
- if err.response.status_code == 401:
1498
- # session is wrong, need to relogin
1499
- self.login()
1500
- return self.get_cam_auth_code(
1501
- serial, encrypt_pwd, msg_auth_code, max_retries + 1
1502
- )
1503
-
1504
- raise HTTPError from err
1505
-
1506
- try:
1507
- json_output = req.json()
1508
-
1509
- except ValueError as err:
1510
- raise PyEzvizError(
1511
- "Impossible to decode response: "
1512
- + str(err)
1513
- + "\nResponse was: "
1514
- + str(req.text)
1515
- ) from err
1257
+ json_output = self._request_json(
1258
+ "GET",
1259
+ f"{API_ENDPOINT_CAM_AUTH_CODE}{serial}",
1260
+ params=params,
1261
+ retry_401=True,
1262
+ max_retries=max_retries,
1263
+ )
1516
1264
 
1517
- if json_output["meta"]["code"] == 80000:
1265
+ if self._meta_code(json_output) == 80000:
1518
1266
  raise EzvizAuthVerificationCode("Operation requires 2FA check")
1519
1267
 
1520
- if json_output["meta"]["code"] == 2009:
1268
+ if self._meta_code(json_output) == 2009:
1521
1269
  raise DeviceException(f"Device not reachable: Got {json_output}")
1522
1270
 
1523
- if json_output["meta"]["code"] != 200:
1271
+ if not self._meta_ok(json_output):
1524
1272
  raise PyEzvizError(
1525
1273
  f"Could not get camera verification key: Got {json_output}"
1526
1274
  )
@@ -1557,39 +1305,18 @@ class EzvizClient:
1557
1305
  }
1558
1306
  }
1559
1307
  """
1560
-
1561
1308
  if max_retries > MAX_RETRIES:
1562
1309
  raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
1563
1310
 
1564
- try:
1565
- req = self._session.post(
1566
- url=f"https://{self._token['api_url']}{API_ENDPOINT_2FA_VALIDATE_POST_AUTH}",
1567
- data={"bizType": biz_type, "from": username},
1568
- timeout=self._timeout,
1569
- )
1570
-
1571
- req.raise_for_status()
1572
-
1573
- except requests.HTTPError as err:
1574
- if err.response.status_code == 401:
1575
- # session is wrong, need to relogin
1576
- self.login()
1577
- return self.get_2fa_check_code(biz_type, username, max_retries + 1)
1578
-
1579
- raise HTTPError from err
1580
-
1581
- try:
1582
- json_output = req.json()
1583
-
1584
- except ValueError as err:
1585
- raise PyEzvizError(
1586
- "Impossible to decode response: "
1587
- + str(err)
1588
- + "\nResponse was: "
1589
- + str(req.text)
1590
- ) from err
1311
+ json_output = self._request_json(
1312
+ "POST",
1313
+ API_ENDPOINT_2FA_VALIDATE_POST_AUTH,
1314
+ data={"bizType": biz_type, "from": username},
1315
+ retry_401=True,
1316
+ max_retries=max_retries,
1317
+ )
1591
1318
 
1592
- if json_output["meta"]["code"] != 200:
1319
+ if not self._meta_ok(json_output):
1593
1320
  raise PyEzvizError(
1594
1321
  f"Could not request elevated permission: Got {json_output})"
1595
1322
  )
@@ -1598,97 +1325,51 @@ class EzvizClient:
1598
1325
 
1599
1326
  def create_panoramic(self, serial: str, max_retries: int = 0) -> Any:
1600
1327
  """Create panoramic image."""
1601
-
1602
1328
  if max_retries > MAX_RETRIES:
1603
1329
  raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
1604
1330
 
1605
- try:
1606
- req = self._session.post(
1607
- url=f"https://{self._token['api_url']}{API_ENDPOINT_CREATE_PANORAMIC}",
1331
+ attempts = max(0, max_retries)
1332
+ for attempt in range(attempts + 1):
1333
+ json_output = self._request_json(
1334
+ "POST",
1335
+ API_ENDPOINT_CREATE_PANORAMIC,
1608
1336
  data={"deviceSerial": serial},
1609
- timeout=self._timeout,
1337
+ retry_401=True,
1338
+ max_retries=0,
1610
1339
  )
1611
-
1612
- req.raise_for_status()
1613
-
1614
- except requests.HTTPError as err:
1615
- if err.response.status_code == 401:
1616
- # session is wrong, need to relogin
1617
- self.login()
1618
- return self.create_panoramic(serial, max_retries + 1)
1619
-
1620
- raise HTTPError from err
1621
-
1622
- try:
1623
- json_output = req.json()
1624
-
1625
- except ValueError as err:
1626
- raise PyEzvizError(
1627
- "Impossible to decode response: "
1628
- + str(err)
1629
- + "\nResponse was: "
1630
- + str(req.text)
1631
- ) from err
1632
-
1633
- if json_output["resultCode"] != "0":
1634
- if json_output["resultCode"] == "-1":
1340
+ result = str(json_output.get("resultCode"))
1341
+ if result == "0":
1342
+ return json_output
1343
+ if result == "-1" and attempt < attempts:
1635
1344
  _LOGGER.warning(
1636
- "Create panoramic failed on device %s retrying %s",
1345
+ "Create panoramic failed on device %s retrying %s/%s",
1637
1346
  serial,
1638
- max_retries,
1347
+ attempt + 1,
1348
+ attempts,
1639
1349
  )
1640
- return self.create_panoramic(serial, max_retries + 1)
1350
+ continue
1641
1351
  raise PyEzvizError(
1642
1352
  f"Could not send command to create panoramic photo: Got {json_output})"
1643
1353
  )
1644
-
1645
- return json_output
1354
+ raise PyEzvizError("Could not send command to create panoramic photo: exceeded retries")
1646
1355
 
1647
1356
  def return_panoramic(self, serial: str, max_retries: int = 0) -> Any:
1648
1357
  """Return panoramic image url list."""
1649
-
1650
- if max_retries > MAX_RETRIES:
1651
- raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
1652
-
1653
- try:
1654
- req = self._session.post(
1655
- url=f"https://{self._token['api_url']}{API_ENDPOINT_RETURN_PANORAMIC}",
1358
+ json_output = self._retry_json(
1359
+ lambda: self._request_json(
1360
+ "POST",
1361
+ API_ENDPOINT_RETURN_PANORAMIC,
1656
1362
  data={"deviceSerial": serial},
1657
- timeout=self._timeout,
1658
- )
1659
-
1660
- req.raise_for_status()
1661
-
1662
- except requests.HTTPError as err:
1663
- if err.response.status_code == 401:
1664
- # session is wrong, need to relogin
1665
- self.login()
1666
- return self.return_panoramic(serial, max_retries + 1)
1667
-
1668
- raise HTTPError from err
1669
-
1670
- try:
1671
- json_output = req.json()
1672
-
1673
- except ValueError as err:
1674
- raise PyEzvizError(
1675
- "Impossible to decode response: "
1676
- + str(err)
1677
- + "\nResponse was: "
1678
- + str(req.text)
1679
- ) from err
1680
-
1681
- if json_output["resultCode"] != "0":
1682
- if json_output["resultCode"] == "-1":
1683
- _LOGGER.warning(
1684
- "Camera %s busy or unreachable, retrying %s of %s",
1685
- serial,
1686
- max_retries,
1687
- MAX_RETRIES,
1688
- )
1689
- return self.return_panoramic(serial, max_retries + 1)
1363
+ retry_401=True,
1364
+ max_retries=0,
1365
+ ),
1366
+ attempts=max_retries,
1367
+ should_retry=lambda p: str(p.get("resultCode")) == "-1",
1368
+ log="panoramic_busy_or_unreachable",
1369
+ serial=serial,
1370
+ )
1371
+ if str(json_output.get("resultCode")) != "0":
1690
1372
  raise PyEzvizError(f"Could retrieve panoramic photo: Got {json_output})")
1691
-
1692
1373
  return json_output
1693
1374
 
1694
1375
  def ptz_control_coordinates(
@@ -1705,34 +1386,23 @@ class EzvizClient:
1705
1386
  f"Invalid Y coordinate: {y_axis}: Should be between 0 and 1 inclusive"
1706
1387
  )
1707
1388
 
1708
- try:
1709
- req = self._session.post(
1710
- url=f"https://{self._token['api_url']}{API_ENDPOINT_PANORAMIC_DEVICES_OPERATION}",
1711
- data={
1712
- "x": f"{x_axis:.6f}",
1713
- "y": f"{y_axis:.6f}",
1714
- "deviceSerial": serial,
1715
- },
1716
- timeout=self._timeout,
1717
- )
1718
-
1719
- req.raise_for_status()
1720
-
1721
- except requests.HTTPError as err:
1722
- raise HTTPError from err
1723
-
1724
- try:
1725
- json_result = req.json()
1726
-
1727
- except ValueError as err:
1728
- raise PyEzvizError(
1729
- "Impossible to decode response: "
1730
- + str(err)
1731
- + "\nResponse was: "
1732
- + str(req.text)
1733
- ) from err
1389
+ json_result = self._request_json(
1390
+ "POST",
1391
+ API_ENDPOINT_PANORAMIC_DEVICES_OPERATION,
1392
+ data={
1393
+ "x": f"{x_axis:.6f}",
1394
+ "y": f"{y_axis:.6f}",
1395
+ "deviceSerial": serial,
1396
+ },
1397
+ retry_401=False,
1398
+ )
1734
1399
 
1735
- _LOGGER.debug("PTZ control coordinates: %s", json_result)
1400
+ _LOGGER.debug(
1401
+ "http_debug: serial=%s code=%s msg=%s",
1402
+ serial,
1403
+ self._meta_code(json_result),
1404
+ "ptz_control_coordinates",
1405
+ )
1736
1406
 
1737
1407
  return True
1738
1408
 
@@ -1746,50 +1416,33 @@ class EzvizClient:
1746
1416
 
1747
1417
  Raises:
1748
1418
  PyEzvizError: If max retries are exceeded or if the response indicates failure.
1749
- HTTPError: If an HTTP error occurs (other than a 401, which triggers re-login).
1750
-
1751
- Returns:
1752
- bool: True if the operation was successful.
1753
-
1754
- """
1755
- try:
1756
- headers = self._session.headers
1757
- headers.update({"Content-Type": "application/json"})
1758
-
1759
- req = self._session.put(
1760
- url=f"https://{self._token['api_url']}{API_ENDPOINT_IOT_ACTION}{serial}{API_ENDPOINT_REMOTE_UNLOCK}",
1761
- data=json.dumps(
1762
- {
1763
- "unLockInfo": {
1764
- "bindCode": f"{FEATURE_CODE}{user_id}",
1765
- "lockNo": lock_no,
1766
- "streamToken": "",
1767
- "userName": user_id,
1768
- }
1769
- }
1770
- ),
1771
- timeout=self._timeout,
1772
- headers=headers,
1773
- )
1774
-
1775
- req.raise_for_status()
1776
-
1777
- except requests.HTTPError as err:
1778
- raise HTTPError from err
1779
-
1780
- try:
1781
- json_result = req.json()
1782
-
1783
- except ValueError as err:
1784
- raise PyEzvizError(
1785
- "Impossible to decode response: "
1786
- + str(err)
1787
- + "\nResponse was: "
1788
- + str(req.text)
1789
- ) from err
1419
+ HTTPError: If an HTTP error occurs (other than a 401, which triggers re-login).
1790
1420
 
1791
- _LOGGER.debug("Result: %s", json_result)
1421
+ Returns:
1422
+ bool: True if the operation was successful.
1792
1423
 
1424
+ """
1425
+ payload = {
1426
+ "unLockInfo": {
1427
+ "bindCode": f"{FEATURE_CODE}{user_id}",
1428
+ "lockNo": lock_no,
1429
+ "streamToken": "",
1430
+ "userName": user_id,
1431
+ }
1432
+ }
1433
+ json_result = self._request_json(
1434
+ "PUT",
1435
+ f"{API_ENDPOINT_IOT_ACTION}{serial}{API_ENDPOINT_REMOTE_UNLOCK}",
1436
+ json_body=payload,
1437
+ retry_401=True,
1438
+ max_retries=0,
1439
+ )
1440
+ _LOGGER.debug(
1441
+ "http_debug: serial=%s code=%s msg=%s",
1442
+ serial,
1443
+ self._response_code(json_result),
1444
+ "remote_unlock",
1445
+ )
1793
1446
  return True
1794
1447
 
1795
1448
  def login(self, sms_code: int | None = None) -> dict[Any, Any]:
@@ -1832,7 +1485,7 @@ class EzvizClient:
1832
1485
  if not self._token.get("service_urls"):
1833
1486
  self._token["service_urls"] = self.get_service_urls()
1834
1487
 
1835
- return self._token
1488
+ return cast(dict[Any, Any], self._token)
1836
1489
 
1837
1490
  if json_result["meta"]["code"] == 403:
1838
1491
  if self.account and self.password:
@@ -1866,7 +1519,12 @@ class EzvizClient:
1866
1519
 
1867
1520
  except requests.HTTPError as err:
1868
1521
  if err.response.status_code == 401:
1869
- _LOGGER.warning("Token is no longer valid. Already logged out?")
1522
+ _LOGGER.warning(
1523
+ "http_warning: serial=%s code=%s msg=%s",
1524
+ "unknown",
1525
+ 401,
1526
+ "logout_already_invalid",
1527
+ )
1870
1528
  return True
1871
1529
  raise HTTPError from err
1872
1530
 
@@ -1887,7 +1545,7 @@ class EzvizClient:
1887
1545
 
1888
1546
  def set_camera_defence_old(self, serial: str, enable: int) -> bool:
1889
1547
  """Enable/Disable motion detection on camera."""
1890
- cas_client = EzvizCAS(self._token)
1548
+ cas_client = EzvizCAS(cast(dict[str, Any], self._token))
1891
1549
  cas_client.set_camera_defence_state(serial, enable)
1892
1550
 
1893
1551
  return True
@@ -1908,91 +1566,33 @@ class EzvizClient:
1908
1566
  + schedule
1909
1567
  + "]}]}"
1910
1568
  )
1911
- try:
1912
- req = self._session.post(
1913
- url=f"https://{self._token['api_url']}{API_ENDPOINT_SET_DEFENCE_SCHEDULE}",
1914
- data={
1915
- "devTimingPlan": schedulestring,
1916
- },
1917
- timeout=self._timeout,
1918
- )
1919
-
1920
- req.raise_for_status()
1921
-
1922
- except requests.HTTPError as err:
1923
- if err.response.status_code == 401:
1924
- # session is wrong, need to relogin
1925
- self.login()
1926
- return self.api_set_defence_schedule(
1927
- serial, schedule, enable, max_retries + 1
1928
- )
1929
-
1930
- raise HTTPError from err
1931
-
1932
- try:
1933
- json_output = req.json()
1934
-
1935
- except ValueError as err:
1936
- raise PyEzvizError(
1937
- "Impossible to decode response: "
1938
- + str(err)
1939
- + "\nResponse was: "
1940
- + str(req.text)
1941
- ) from err
1942
-
1943
- if json_output["resultCode"] != "0":
1944
- if json_output["resultCode"] == "-1":
1945
- _LOGGER.warning(
1946
- "Camara %s offline or unreachable, retrying %s of %s",
1947
- serial,
1948
- max_retries,
1949
- MAX_RETRIES,
1950
- )
1951
- return self.api_set_defence_schedule(
1952
- serial, schedule, enable, max_retries + 1
1953
- )
1569
+ json_output = self._retry_json(
1570
+ lambda: self._request_json(
1571
+ "POST",
1572
+ API_ENDPOINT_SET_DEFENCE_SCHEDULE,
1573
+ data={"devTimingPlan": schedulestring},
1574
+ retry_401=True,
1575
+ max_retries=0,
1576
+ ),
1577
+ attempts=max_retries,
1578
+ should_retry=lambda p: str(p.get("resultCode")) == "-1",
1579
+ log="defence_schedule_offline_or_unreachable",
1580
+ serial=serial,
1581
+ )
1582
+ if str(json_output.get("resultCode")) not in ("0", 0):
1954
1583
  raise PyEzvizError(f"Could not set the schedule: Got {json_output})")
1955
-
1956
1584
  return True
1957
1585
 
1958
1586
  def api_set_defence_mode(self, mode: DefenseModeType, max_retries: int = 0) -> bool:
1959
1587
  """Set defence mode for all devices. The alarm panel from main page is used."""
1960
- if max_retries > MAX_RETRIES:
1961
- raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
1962
- try:
1963
- req = self._session.post(
1964
- url=f"https://{self._token['api_url']}{API_ENDPOINT_SWITCH_DEFENCE_MODE}",
1965
- data={
1966
- "groupId": -1,
1967
- "mode": mode,
1968
- },
1969
- timeout=self._timeout,
1970
- )
1971
-
1972
- req.raise_for_status()
1973
-
1974
- except requests.HTTPError as err:
1975
- if err.response.status_code == 401:
1976
- # session is wrong, need to relogin
1977
- self.login()
1978
- return self.api_set_defence_mode(mode, max_retries + 1)
1979
-
1980
- raise HTTPError from err
1981
-
1982
- try:
1983
- json_output = req.json()
1984
-
1985
- except ValueError as err:
1986
- raise PyEzvizError(
1987
- "Impossible to decode response: "
1988
- + str(err)
1989
- + "\nResponse was: "
1990
- + str(req.text)
1991
- ) from err
1992
-
1993
- if json_output["meta"]["code"] != 200:
1994
- raise PyEzvizError(f"Could not set defence mode: Got {json_output})")
1995
-
1588
+ json_output = self._request_json(
1589
+ "POST",
1590
+ API_ENDPOINT_SWITCH_DEFENCE_MODE,
1591
+ data={"groupId": -1, "mode": mode},
1592
+ retry_401=True,
1593
+ max_retries=max_retries,
1594
+ )
1595
+ self._ensure_ok(json_output, "Could not set defence mode")
1996
1596
  return True
1997
1597
 
1998
1598
  def do_not_disturb(
@@ -2003,34 +1603,14 @@ class EzvizClient:
2003
1603
  max_retries: int = 0,
2004
1604
  ) -> bool:
2005
1605
  """Set do not disturb on camera with specified serial."""
2006
- if max_retries > MAX_RETRIES:
2007
- raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
2008
-
2009
- try:
2010
- req = self._session.put(
2011
- url=f"https://{self._token['api_url']}{API_ENDPOINT_V3_ALARMS}{serial}/{channelno}{API_ENDPOINT_DO_NOT_DISTURB}",
2012
- data={"enable": enable},
2013
- timeout=self._timeout,
2014
- )
2015
- req.raise_for_status()
2016
-
2017
- except requests.HTTPError as err:
2018
- if err.response.status_code == 401:
2019
- # session is wrong, need to re-log-in
2020
- self.login()
2021
- return self.do_not_disturb(serial, enable, channelno, max_retries + 1)
2022
-
2023
- raise HTTPError from err
2024
-
2025
- try:
2026
- json_output = req.json()
2027
-
2028
- except ValueError as err:
2029
- raise PyEzvizError("Could not decode response:" + str(err)) from err
2030
-
2031
- if json_output["meta"]["code"] != 200:
2032
- raise PyEzvizError(f"Could not set do not disturb: Got {json_output})")
2033
-
1606
+ json_output = self._request_json(
1607
+ "PUT",
1608
+ f"{API_ENDPOINT_V3_ALARMS}{serial}/{channelno}{API_ENDPOINT_DO_NOT_DISTURB}",
1609
+ data={"enable": enable},
1610
+ retry_401=True,
1611
+ max_retries=max_retries,
1612
+ )
1613
+ self._ensure_ok(json_output, "Could not set do not disturb")
2034
1614
  return True
2035
1615
 
2036
1616
  def set_answer_call(
@@ -2040,32 +1620,14 @@ class EzvizClient:
2040
1620
  max_retries: int = 0,
2041
1621
  ) -> bool:
2042
1622
  """Set answer call on camera with specified serial."""
2043
- if max_retries > MAX_RETRIES:
2044
- raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
2045
- try:
2046
- req = self._session.put(
2047
- url=f"https://{self._token['api_url']}{API_ENDPOINT_CALLING_NOTIFY}{serial}{API_ENDPOINT_DO_NOT_DISTURB}",
2048
- data={"deviceSerial": serial, "switchStatus": enable},
2049
- timeout=self._timeout,
2050
- )
2051
- req.raise_for_status()
2052
-
2053
- except requests.HTTPError as err:
2054
- if err.response.status_code == 401:
2055
- # session is wrong, need to re-log-in
2056
- self.login()
2057
- return self.set_answer_call(serial, enable, max_retries + 1)
2058
-
2059
- raise HTTPError from err
2060
-
2061
- try:
2062
- json_output = req.json()
2063
-
2064
- except ValueError as err:
2065
- raise PyEzvizError("Could not decode response:" + str(err)) from err
2066
-
2067
- if json_output["meta"]["code"] != 200:
2068
- raise PyEzvizError(f"Could not set answer call: Got {json_output})")
1623
+ json_output = self._request_json(
1624
+ "PUT",
1625
+ f"{API_ENDPOINT_CALLING_NOTIFY}{serial}{API_ENDPOINT_DO_NOT_DISTURB}",
1626
+ data={"deviceSerial": serial, "switchStatus": enable},
1627
+ retry_401=True,
1628
+ max_retries=max_retries,
1629
+ )
1630
+ self._ensure_ok(json_output, "Could not set answer call")
2069
1631
 
2070
1632
  return True
2071
1633
 
@@ -2100,40 +1662,18 @@ class EzvizClient:
2100
1662
  """
2101
1663
  if max_retries > MAX_RETRIES:
2102
1664
  raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
2103
-
2104
- url = (
2105
- f"https://{self._token['api_url']}"
2106
- f"{API_ENDPOINT_INTELLIGENT_APP}{serial}/{resource_id}/{app_name}"
2107
- )
2108
-
2109
- try:
2110
- # Determine which method to call based on the parameter.
2111
- action = action.lower()
2112
- if action == "add":
2113
- req = self._session.put(url=url, timeout=self._timeout)
2114
- elif action == "remove":
2115
- req = self._session.delete(url=url, timeout=self._timeout)
2116
- else:
2117
- raise PyEzvizError(f"Invalid action '{action}'. Use 'add' or 'remove'.")
2118
-
2119
- req.raise_for_status()
2120
-
2121
- except requests.HTTPError as err:
2122
- if err.response.status_code == 401:
2123
- # Session might be invalid; attempt to re-login and retry.
2124
- self.login()
2125
- return self.manage_intelligent_app(
2126
- serial, resource_id, app_name, action, max_retries + 1
2127
- )
2128
- raise HTTPError from err
2129
-
2130
- try:
2131
- json_output = req.json()
2132
- except ValueError as err:
2133
- raise PyEzvizError("Could not decode response: " + str(err)) from err
2134
-
2135
- if json_output.get("meta", {}).get("code") != 200:
2136
- raise PyEzvizError(f"Could not {action} intelligent app: Got {json_output}")
1665
+ url_path = f"{API_ENDPOINT_INTELLIGENT_APP}{serial}/{resource_id}/{app_name}"
1666
+ # Determine which method to call based on the parameter.
1667
+ action = action.lower()
1668
+ if action == "add":
1669
+ method = "PUT"
1670
+ elif action == "remove":
1671
+ method = "DELETE"
1672
+ else:
1673
+ raise PyEzvizError(f"Invalid action '{action}'. Use 'add' or 'remove'.")
1674
+
1675
+ json_output = self._request_json(method, url_path, retry_401=True, max_retries=max_retries)
1676
+ self._ensure_ok(json_output, f"Could not {action} intelligent app")
2137
1677
 
2138
1678
  return True
2139
1679
 
@@ -2158,31 +1698,13 @@ class EzvizClient:
2158
1698
  bool: True if the operation was successful.
2159
1699
 
2160
1700
  """
2161
- if max_retries > MAX_RETRIES:
2162
- raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
2163
-
2164
- try:
2165
- req = self._session.put(
2166
- url=f"https://{self._token['api_url']}{API_ENDPOINT_DEVICE_BASICS}{serial}/{channel}/CENTER/mirror",
2167
- timeout=self._timeout,
2168
- )
2169
- req.raise_for_status()
2170
-
2171
- except requests.HTTPError as err:
2172
- if err.response.status_code == 401:
2173
- # Session might be invalid; attempt to re-login and retry.
2174
- self.login()
2175
- return self.flip_image(serial, channel, max_retries + 1)
2176
- raise HTTPError from err
2177
-
2178
- try:
2179
- json_output = req.json()
2180
-
2181
- except ValueError as err:
2182
- raise PyEzvizError("Could not decode response: " + str(err)) from err
2183
-
2184
- if json_output.get("meta", {}).get("code") != 200:
2185
- raise PyEzvizError(f"Could not flip image on camera: Got {json_output}")
1701
+ json_output = self._request_json(
1702
+ "PUT",
1703
+ f"{API_ENDPOINT_DEVICE_BASICS}{serial}/{channel}/CENTER/mirror",
1704
+ retry_401=True,
1705
+ max_retries=max_retries,
1706
+ )
1707
+ self._ensure_ok(json_output, "Could not flip image on camera")
2186
1708
 
2187
1709
  return True
2188
1710
 
@@ -2209,32 +1731,14 @@ class EzvizClient:
2209
1731
  bool: True if the operation was successful.
2210
1732
 
2211
1733
  """
2212
- if max_retries > MAX_RETRIES:
2213
- raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
2214
-
2215
- try:
2216
- req = self._session.put(
2217
- url=f"https://{self._token['api_url']}{API_ENDPOINT_OSD}{serial}/{channel}/osd",
2218
- data={"osd": text},
2219
- timeout=self._timeout,
2220
- )
2221
- req.raise_for_status()
2222
-
2223
- except requests.HTTPError as err:
2224
- if err.response.status_code == 401:
2225
- # Session might be invalid; attempt to re-login and retry.
2226
- self.login()
2227
- return self.set_camera_osd(serial, text, channel, max_retries + 1)
2228
- raise HTTPError from err
2229
-
2230
- try:
2231
- json_output = req.json()
2232
-
2233
- except ValueError as err:
2234
- raise PyEzvizError("Could not decode response: " + str(err)) from err
2235
-
2236
- if json_output.get("meta", {}).get("code") != 200:
2237
- raise PyEzvizError(f"Could set osd message on camera: Got {json_output}")
1734
+ json_output = self._request_json(
1735
+ "PUT",
1736
+ f"{API_ENDPOINT_OSD}{serial}/{channel}/osd",
1737
+ data={"osd": text},
1738
+ retry_401=True,
1739
+ max_retries=max_retries,
1740
+ )
1741
+ self._ensure_ok(json_output, "Could set osd message on camera")
2238
1742
 
2239
1743
  return True
2240
1744
 
@@ -2254,35 +1758,14 @@ class EzvizClient:
2254
1758
  "Range of luminance is 1-100, got " + str(luminance) + "."
2255
1759
  )
2256
1760
 
2257
- try:
2258
- req = self._session.post(
2259
- url=f"https://{self._token['api_url']}{API_ENDPOINT_SET_LUMINANCE}{serial}/{channelno}",
2260
- data={
2261
- "luminance": luminance,
2262
- },
2263
- timeout=self._timeout,
2264
- )
2265
-
2266
- req.raise_for_status()
2267
-
2268
- except requests.HTTPError as err:
2269
- if err.response.status_code == 401:
2270
- # session is wrong, need to re-log-in
2271
- self.login()
2272
- return self.set_floodlight_brightness(
2273
- serial, luminance, channelno, max_retries + 1
2274
- )
2275
-
2276
- raise HTTPError from err
2277
-
2278
- try:
2279
- response_json = req.json()
2280
-
2281
- except ValueError as err:
2282
- raise PyEzvizError("Could not decode response:" + str(err)) from err
2283
-
2284
- if response_json["meta"]["code"] != 200:
2285
- raise PyEzvizError(f"Unable to set brightness, got: {response_json}")
1761
+ response_json = self._request_json(
1762
+ "POST",
1763
+ f"{API_ENDPOINT_SET_LUMINANCE}{serial}/{channelno}",
1764
+ data={"luminance": luminance},
1765
+ retry_401=True,
1766
+ max_retries=max_retries,
1767
+ )
1768
+ self._ensure_ok(response_json, "Unable to set brightness")
2286
1769
 
2287
1770
  return True
2288
1771
 
@@ -2294,7 +1777,6 @@ class EzvizClient:
2294
1777
  max_retries: int = 0,
2295
1778
  ) -> bool | str:
2296
1779
  """Facade that changes the brightness to light bulbs or cameras' light."""
2297
-
2298
1780
  device = self._light_bulbs.get(serial)
2299
1781
  if device:
2300
1782
  # the device is a light bulb
@@ -2313,7 +1795,6 @@ class EzvizClient:
2313
1795
  max_retries: int = 0,
2314
1796
  ) -> bool:
2315
1797
  """Facade that turns on/off light bulbs or cameras' light."""
2316
-
2317
1798
  device = self._light_bulbs.get(serial)
2318
1799
  if device:
2319
1800
  # the device is a light bulb
@@ -2392,54 +1873,27 @@ class EzvizClient:
2392
1873
  self, serial: str, type_value: str = "0", max_retries: int = 0
2393
1874
  ) -> Any:
2394
1875
  """Get detection sensibility notifications."""
2395
- if max_retries > MAX_RETRIES:
2396
- raise PyEzvizError("Can't gather proper data. Max retries exceeded.")
2397
- try:
2398
- req = self._session.post(
2399
- url=f"https://{self._token['api_url']}{API_ENDPOINT_DETECTION_SENSIBILITY_GET}",
2400
- data={
2401
- "subSerial": serial,
2402
- },
2403
- timeout=self._timeout,
2404
- )
2405
-
2406
- req.raise_for_status()
2407
-
2408
- except requests.HTTPError as err:
2409
- if err.response.status_code == 401:
2410
- # session is wrong, need to re-log-in.
2411
- self.login()
2412
- return self.get_detection_sensibility(
2413
- serial, type_value, max_retries + 1
2414
- )
2415
-
2416
- raise HTTPError from err
2417
-
2418
- try:
2419
- response_json = req.json()
2420
-
2421
- except ValueError as err:
2422
- raise PyEzvizError("Could not decode response:" + str(err)) from err
2423
-
2424
- if response_json["resultCode"] != "0":
2425
- if response_json["resultCode"] == "-1":
2426
- _LOGGER.warning(
2427
- "Camera %s is offline or unreachable, retrying %s of %s",
2428
- serial,
2429
- max_retries,
2430
- MAX_RETRIES,
2431
- )
2432
- return self.get_detection_sensibility(
2433
- serial, type_value, max_retries + 1
2434
- )
1876
+ response_json = self._retry_json(
1877
+ lambda: self._request_json(
1878
+ "POST",
1879
+ API_ENDPOINT_DETECTION_SENSIBILITY_GET,
1880
+ data={"subSerial": serial},
1881
+ retry_401=True,
1882
+ max_retries=0,
1883
+ ),
1884
+ attempts=max_retries,
1885
+ should_retry=lambda p: str(p.get("resultCode")) == "-1",
1886
+ log=f"Camera {serial} is offline or unreachable",
1887
+ )
1888
+ if str(response_json.get("resultCode")) != "0":
2435
1889
  raise PyEzvizError(
2436
1890
  f"Unable to get detection sensibility. Got: {response_json}"
2437
1891
  )
2438
1892
 
2439
- if response_json["algorithmConfig"]["algorithmList"]:
1893
+ if response_json.get("algorithmConfig", {}).get("algorithmList"):
2440
1894
  for idx in response_json["algorithmConfig"]["algorithmList"]:
2441
- if idx["type"] == type_value:
2442
- return idx["value"]
1895
+ if idx.get("type") == type_value:
1896
+ return idx.get("value")
2443
1897
 
2444
1898
  return None
2445
1899
 
@@ -2456,37 +1910,39 @@ class EzvizClient:
2456
1910
  "Invalid sound_type, should be 0,1,2: " + str(sound_type)
2457
1911
  )
2458
1912
 
2459
- try:
2460
- req = self._session.put(
2461
- url=f"https://{self._token['api_url']}{API_ENDPOINT_DEVICES}{serial}{API_ENDPOINT_ALARM_SOUND}",
2462
- data={
2463
- "enable": enable,
2464
- "soundType": sound_type,
2465
- "voiceId": "0",
2466
- "deviceSerial": serial,
2467
- },
1913
+ response_json = self._request_json(
1914
+ "PUT",
1915
+ f"{API_ENDPOINT_DEVICES}{serial}{API_ENDPOINT_ALARM_SOUND}",
1916
+ data={
1917
+ "enable": enable,
1918
+ "soundType": sound_type,
1919
+ "voiceId": "0",
1920
+ "deviceSerial": serial,
1921
+ },
1922
+ retry_401=True,
1923
+ max_retries=max_retries,
1924
+ )
1925
+ self._ensure_ok(response_json, "Could not set alarm sound")
1926
+ _LOGGER.debug(
1927
+ "http_debug: serial=%s code=%s msg=%s",
1928
+ serial,
1929
+ self._meta_code(response_json),
1930
+ "alarm_sound",
1931
+ )
1932
+ return True
1933
+
1934
+ def get_mqtt_client(
1935
+ self, on_message_callback: Callable[[dict[str, Any]], None] | None = None
1936
+ ) -> MQTTClient:
1937
+ """Return a configured MQTTClient using this client's session."""
1938
+ if self.mqtt_client is None:
1939
+ self.mqtt_client = MQTTClient(
1940
+ token=cast(dict[Any, Any], self._token),
1941
+ session=self._session,
2468
1942
  timeout=self._timeout,
1943
+ on_message_callback=on_message_callback,
2469
1944
  )
2470
-
2471
- req.raise_for_status()
2472
-
2473
- except requests.HTTPError as err:
2474
- if err.response.status_code == 401:
2475
- # session is wrong, need to re-log-in
2476
- self.login()
2477
- return self.alarm_sound(serial, sound_type, enable, max_retries + 1)
2478
-
2479
- raise HTTPError from err
2480
-
2481
- try:
2482
- response_json = req.json()
2483
-
2484
- except ValueError as err:
2485
- raise PyEzvizError("Could not decode response:" + str(err)) from err
2486
-
2487
- _LOGGER.debug("Response: %s", response_json)
2488
-
2489
- return True
1945
+ return self.mqtt_client
2490
1946
 
2491
1947
  def _get_page_list(self) -> Any:
2492
1948
  """Get ezviz device info broken down in sections."""