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