pyvesync 3.0.0__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.
Files changed (50) hide show
  1. pyvesync/__init__.py +5 -0
  2. pyvesync/base_devices/__init__.py +35 -0
  3. pyvesync/base_devices/bulb_base.py +330 -0
  4. pyvesync/base_devices/fan_base.py +392 -0
  5. pyvesync/base_devices/fryer_base.py +68 -0
  6. pyvesync/base_devices/humidifier_base.py +462 -0
  7. pyvesync/base_devices/outlet_base.py +327 -0
  8. pyvesync/base_devices/purifier_base.py +525 -0
  9. pyvesync/base_devices/switch_base.py +271 -0
  10. pyvesync/base_devices/thermostat_base.py +283 -0
  11. pyvesync/base_devices/vesyncbasedevice.py +572 -0
  12. pyvesync/const.py +825 -0
  13. pyvesync/device_container.py +373 -0
  14. pyvesync/device_map.py +1172 -0
  15. pyvesync/devices/__init__.py +1 -0
  16. pyvesync/devices/vesyncbulb.py +1126 -0
  17. pyvesync/devices/vesyncfan.py +233 -0
  18. pyvesync/devices/vesynchumidifier.py +808 -0
  19. pyvesync/devices/vesynckitchen.py +651 -0
  20. pyvesync/devices/vesyncoutlet.py +954 -0
  21. pyvesync/devices/vesyncpurifier.py +990 -0
  22. pyvesync/devices/vesyncswitch.py +415 -0
  23. pyvesync/devices/vesyncthermostat.py +171 -0
  24. pyvesync/models/__init__.py +11 -0
  25. pyvesync/models/base_models.py +129 -0
  26. pyvesync/models/bulb_models.py +197 -0
  27. pyvesync/models/bypass_models.py +289 -0
  28. pyvesync/models/fan_models.py +47 -0
  29. pyvesync/models/fryer_models.py +34 -0
  30. pyvesync/models/home_models.py +169 -0
  31. pyvesync/models/humidifier_models.py +202 -0
  32. pyvesync/models/outlet_models.py +220 -0
  33. pyvesync/models/purifier_models.py +261 -0
  34. pyvesync/models/switch_models.py +101 -0
  35. pyvesync/models/thermostat_models.py +134 -0
  36. pyvesync/models/vesync_models.py +439 -0
  37. pyvesync/utils/__init__.py +1 -0
  38. pyvesync/utils/colors.py +250 -0
  39. pyvesync/utils/device_mixins.py +362 -0
  40. pyvesync/utils/enum_utils.py +26 -0
  41. pyvesync/utils/errors.py +867 -0
  42. pyvesync/utils/helpers.py +700 -0
  43. pyvesync/utils/logs.py +525 -0
  44. pyvesync/vesync.py +638 -0
  45. pyvesync/vesynchome.py +152 -0
  46. pyvesync-3.0.0.dist-info/METADATA +567 -0
  47. pyvesync-3.0.0.dist-info/RECORD +50 -0
  48. pyvesync-3.0.0.dist-info/WHEEL +5 -0
  49. pyvesync-3.0.0.dist-info/licenses/LICENSE +21 -0
  50. pyvesync-3.0.0.dist-info/top_level.txt +1 -0
pyvesync/vesync.py ADDED
@@ -0,0 +1,638 @@
1
+ """VeSync API Device Libary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from dataclasses import MISSING, fields
8
+ from pathlib import Path
9
+ from typing import Self
10
+
11
+ from aiohttp import ClientSession
12
+ from aiohttp.client_exceptions import ClientResponseError
13
+ from mashumaro.exceptions import MissingField, UnserializableDataError
14
+ from mashumaro.mixins.orjson import DataClassORJSONMixin
15
+
16
+ from pyvesync.const import (
17
+ API_BASE_URL_EU,
18
+ API_BASE_URL_US,
19
+ DEFAULT_REGION,
20
+ DEFAULT_TZ,
21
+ NON_EU_REGIONS,
22
+ STATUS_OK,
23
+ )
24
+ from pyvesync.device_container import DeviceContainer, DeviceContainerInstance
25
+ from pyvesync.models.vesync_models import (
26
+ FirmwareDeviceItemModel,
27
+ RequestDeviceListModel,
28
+ RequestFirmwareModel,
29
+ RequestGetTokenModel,
30
+ RequestLoginTokenModel,
31
+ RespGetTokenResultModel,
32
+ RespLoginTokenResultModel,
33
+ ResponseDeviceListModel,
34
+ ResponseFirmwareModel,
35
+ ResponseLoginModel,
36
+ )
37
+ from pyvesync.utils.errors import (
38
+ ErrorCodes,
39
+ ErrorTypes,
40
+ VeSyncAPIResponseError,
41
+ VeSyncAPIStatusCodeError,
42
+ VeSyncError,
43
+ VeSyncLoginError,
44
+ VeSyncServerError,
45
+ raise_api_errors,
46
+ )
47
+ from pyvesync.utils.helpers import Helpers
48
+ from pyvesync.utils.logs import LibraryLogger
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ class VeSync: # pylint: disable=function-redefined
54
+ """VeSync Manager Class."""
55
+
56
+ __slots__ = (
57
+ '__weakref__',
58
+ '_account_id',
59
+ '_close_session',
60
+ '_debug',
61
+ '_device_container',
62
+ '_redact',
63
+ '_token',
64
+ '_verbose',
65
+ 'country_code',
66
+ 'enabled',
67
+ 'in_process',
68
+ 'language',
69
+ 'password',
70
+ 'session',
71
+ 'time_zone',
72
+ 'username',
73
+ )
74
+
75
+ def __init__( # noqa: PLR0913
76
+ self,
77
+ username: str,
78
+ password: str,
79
+ country_code: str = DEFAULT_REGION,
80
+ session: ClientSession | None = None,
81
+ time_zone: str = DEFAULT_TZ,
82
+ debug: bool = False,
83
+ redact: bool = True,
84
+ ) -> None:
85
+ """Initialize VeSync Manager.
86
+
87
+ This class is used as the manager for all VeSync objects, all methods and
88
+ API calls are performed from this class. Time zone, debug and redact are
89
+ optional. Time zone must be a string of an IANA time zone format. Once
90
+ class is instantiated, call `await manager.login()` to log in to VeSync servers,
91
+ which returns `True` if successful. Once logged in, call
92
+ `await manager.get_devices()` to retrieve devices. Then `await `manager.update()`
93
+ to update all devices or `await manager.devices[0].update()` to
94
+ update a single device.
95
+
96
+ Args:
97
+ username (str): VeSync account username (usually email address)
98
+ password (str): VeSync account password
99
+ country_code (str): VeSync account country in ISO 3166 Alpha-2 format.
100
+ By default, the account region is detected automatically at the login step
101
+ If your account country is different from the default `US`,
102
+ a second login attempt may be necessary - in this case
103
+ you should specify the country directly to speed up the login process.
104
+ session (ClientSession): aiohttp client session for
105
+ API calls, by default None
106
+ time_zone (str): Time zone for device from IANA database, by default
107
+ DEFAULT_TZ. This is automatically set to the time zone of the
108
+ VeSync account during login.
109
+ debug (bool): Enable debug logging, by default False.
110
+ redact (bool): Enable redaction of sensitive information, by default True.
111
+
112
+ Attributes:
113
+ session (ClientSession): Client session for API calls
114
+ devices (DeviceContainer): Container for all VeSync devices,
115
+ has functionality of a mutable set. See
116
+ [`DeviceContainer`][pyvesync.device_container.DeviceContainer] for
117
+ more information
118
+ token (str): VeSync API token
119
+ account_id (str): VeSync account ID
120
+ country_code (str): Country code for VeSync account pulled from API
121
+ time_zone (str): Time zone for VeSync account pulled from API
122
+ enabled (bool): True if logged in to VeSync, False if not
123
+
124
+ Note:
125
+ This class is a context manager, use `async with VeSync() as manager:`
126
+ to manage the session context. The session will be closed when exiting
127
+ if no session is passed in.
128
+
129
+ The `manager.devices` attribute is a DeviceContainer object that contains
130
+ all VeSync devices. The `manager.devices` object has the functionality of
131
+ a set, and can be iterated over to access devices. See :obj:`DeviceContainer`
132
+ for more information.
133
+
134
+ If using a context manager is not convenient, `manager.__aenter__()` and
135
+ `manager.__aexit__()` can be called directly.
136
+
137
+ See Also:
138
+ :obj:`DeviceContainer`
139
+ Container object to store VeSync devices
140
+ :obj:`DeviceState`
141
+ Object to store device state information
142
+ """
143
+ self.session = session
144
+ self._close_session = False
145
+ self._debug = debug
146
+ self.redact = redact
147
+ self.username: str = username
148
+ self.password: str = password
149
+ self._token: str | None = None
150
+ self._account_id: str | None = None
151
+ self.country_code: str = country_code.upper()
152
+ self._verbose: bool = False
153
+ self.time_zone: str = time_zone
154
+ self.language: str = 'en'
155
+ self.enabled = False
156
+ self.in_process = False
157
+ self._device_container: DeviceContainer = DeviceContainerInstance
158
+
159
+ @property
160
+ def devices(self) -> DeviceContainer:
161
+ """Return VeSync device container.
162
+
163
+ See Also:
164
+ The pyvesync.device_container.DeviceContainer object
165
+ for methods and properties.
166
+ """
167
+ return self._device_container
168
+
169
+ @property
170
+ def token(self) -> str:
171
+ """Return VeSync API token."""
172
+ if self._token is None:
173
+ raise AttributeError('Token not set, run login() method')
174
+ return self._token
175
+
176
+ @property
177
+ def account_id(self) -> str:
178
+ """Return VeSync account ID."""
179
+ if self._account_id is None:
180
+ raise AttributeError('Account ID not set, run login() method')
181
+ return self._account_id
182
+
183
+ @property
184
+ def debug(self) -> bool:
185
+ """Return debug flag."""
186
+ return self._debug
187
+
188
+ @debug.setter
189
+ def debug(self, new_flag: bool) -> None:
190
+ """Set debug flag."""
191
+ if new_flag:
192
+ LibraryLogger.debug = True
193
+ LibraryLogger.configure_logger(logging.DEBUG)
194
+ else:
195
+ LibraryLogger.debug = False
196
+ LibraryLogger.configure_logger(logging.WARNING)
197
+ self._debug = new_flag
198
+
199
+ @property
200
+ def verbose(self) -> bool:
201
+ """Enable verbose logging."""
202
+ return LibraryLogger.verbose
203
+
204
+ @verbose.setter
205
+ def verbose(self, new_flag: bool) -> None:
206
+ """Set verbose logging."""
207
+ if new_flag:
208
+ LibraryLogger.verbose = True
209
+ LibraryLogger.configure_logger(logging.DEBUG)
210
+ else:
211
+ LibraryLogger.verbose = False
212
+ self._verbose = new_flag
213
+
214
+ @property
215
+ def redact(self) -> bool:
216
+ """Return debug flag."""
217
+ return self._redact
218
+
219
+ @redact.setter
220
+ def redact(self, new_flag: bool) -> None:
221
+ """Set debug flag."""
222
+ if new_flag:
223
+ LibraryLogger.shouldredact = True
224
+ elif new_flag is False:
225
+ LibraryLogger.shouldredact = False
226
+ self._redact = new_flag
227
+
228
+ def log_to_file(self, filename: str | Path, std_out: bool = True) -> None:
229
+ """Log to file and enable debug logging.
230
+
231
+ Args:
232
+ filename (str | Path): The name of the file to log to.
233
+ std_out (bool): If False, logs will not print to std out.
234
+ """
235
+ self.debug = True
236
+ LibraryLogger.configure_logger(logging.DEBUG, file_name=filename, std_out=std_out)
237
+ logger.debug('Logging to file: %s', filename)
238
+
239
+ def process_devices(self, dev_list_resp: ResponseDeviceListModel) -> bool:
240
+ """Instantiate Device Objects.
241
+
242
+ Internal method run by `get_devices()` to instantiate device objects.
243
+
244
+ """
245
+ current_device_count = len(self._device_container)
246
+
247
+ self._device_container.remove_stale_devices(dev_list_resp)
248
+
249
+ new_device_count = len(self._device_container)
250
+ if new_device_count != current_device_count:
251
+ logger.debug(
252
+ 'Removed %s devices', str(current_device_count - new_device_count)
253
+ )
254
+ current_device_count = new_device_count
255
+ self._device_container.add_new_devices(dev_list_resp, self)
256
+ new_device_count = len(self._device_container)
257
+ if new_device_count != current_device_count:
258
+ logger.debug('Added %s devices', str(new_device_count - current_device_count))
259
+ return True
260
+
261
+ async def get_devices(self) -> bool:
262
+ """Return tuple listing outlets, switches, and fans of devices.
263
+
264
+ This is also called by `VeSync.update()`
265
+
266
+ Raises:
267
+ VeSyncAPIResponseError: If API response is invalid.
268
+ VeSyncServerError: If server returns an error.
269
+ """
270
+ if not self.enabled:
271
+ return False
272
+
273
+ self.in_process = True
274
+ proc_return = False
275
+ request_model = RequestDeviceListModel(
276
+ token=self.token, accountID=self.account_id, timeZone=self.time_zone
277
+ )
278
+ response_dict, _ = await self.async_call_api(
279
+ '/cloud/v1/deviceManaged/devices',
280
+ 'post',
281
+ headers=Helpers.req_header_bypass(),
282
+ json_object=request_model.to_dict(),
283
+ )
284
+
285
+ if response_dict is None:
286
+ raise VeSyncAPIResponseError(
287
+ 'Error receiving response to device list request'
288
+ )
289
+
290
+ response = ResponseDeviceListModel.from_dict(response_dict)
291
+
292
+ if response.code == 0:
293
+ proc_return = self.process_devices(response)
294
+ else:
295
+ error_info = ErrorCodes.get_error_info(response.code)
296
+ resp_message = response.msg
297
+ info_msg = f'{error_info.message} ({resp_message})'
298
+ if error_info.error_type == ErrorTypes.SERVER_ERROR:
299
+ raise VeSyncServerError(info_msg)
300
+ raise VeSyncAPIResponseError(
301
+ 'Error receiving response to device list request'
302
+ )
303
+
304
+ self.in_process = False
305
+
306
+ return proc_return
307
+
308
+ async def login(self) -> None: # pylint: disable=W9006 # pylint mult docstring raises
309
+ """Log into VeSync server.
310
+
311
+ Username and password are provided when class is instantiated.
312
+
313
+ Raises:
314
+ VeSyncLoginError: If login fails, for example due to invalid username
315
+ or password.
316
+ VeSyncAPIResponseError: If API response is invalid.
317
+ VeSyncServerError: If server returns an error.
318
+ """
319
+ if (
320
+ not isinstance(self.username, str)
321
+ or len(self.username) == 0
322
+ or not isinstance(self.password, str)
323
+ or len(self.password) == 0
324
+ ):
325
+ raise VeSyncLoginError('Username and password must be specified')
326
+
327
+ request_auth = RequestGetTokenModel(
328
+ email=self.username,
329
+ method='authByPWDOrOTM',
330
+ password=self.password,
331
+ )
332
+ resp_dict, _ = await self.async_call_api(
333
+ '/globalPlatform/api/accountAuth/v1/authByPWDOrOTM',
334
+ 'post',
335
+ json_object=request_auth,
336
+ )
337
+ if resp_dict is None:
338
+ raise VeSyncAPIResponseError('Error receiving response to auth request')
339
+
340
+ if resp_dict.get('code') != 0:
341
+ error_info = ErrorCodes.get_error_info(resp_dict.get('code'))
342
+ resp_message = resp_dict.get('msg')
343
+ if resp_message is not None:
344
+ error_info.message = f'{error_info.message} ({resp_message})'
345
+
346
+ msg = f'Error receiving response to auth request - {error_info.message}'
347
+ raise VeSyncAPIResponseError(msg)
348
+
349
+ try:
350
+ response_model = ResponseLoginModel.from_dict(resp_dict)
351
+ except Exception as exc:
352
+ logger.debug('Error parsing auth response: %s', exc)
353
+ raise VeSyncAPIResponseError(
354
+ 'Error receiving response to auth request'
355
+ ) from exc
356
+
357
+ result = response_model.result
358
+ if not isinstance(result, RespGetTokenResultModel):
359
+ raise VeSyncAPIResponseError(
360
+ 'Error receiving response to login request -'
361
+ ' result is not IntRespAuthResultModel'
362
+ )
363
+
364
+ return await self._login_token(auth_code=result.authorizeCode)
365
+
366
+ async def _login_token(
367
+ self,
368
+ auth_code: str | None = None,
369
+ region_change_token: str | None = None,
370
+ ) -> None: # pylint: disable=W9006 # pylint mult docstring raises
371
+ """Exchanges the authorization code for a token.
372
+
373
+ This completes the login process. If the initial call fails with
374
+ `"login trigger cross region error."`, the region is adjusted and
375
+ another attempt is made.
376
+
377
+ Args:
378
+ auth_code (str): Auth code to use for logging in.
379
+ region_change_token (str): "bizToken" to use when calling this endpoint
380
+ for a second time with a different region.
381
+
382
+ Raises:
383
+ VeSyncLoginError: If login fails, for example due to invalid username
384
+ or password.
385
+ VeSyncAPIResponseError: If API response is invalid.
386
+ VeSyncServerError: If server returns an error.
387
+ """
388
+ request_login = RequestLoginTokenModel(
389
+ method='loginByAuthorizeCode4Vesync',
390
+ authorizeCode=auth_code,
391
+ bizToken=region_change_token,
392
+ userCountryCode=self.country_code,
393
+ regionChange='last_region' if region_change_token else None,
394
+ )
395
+ resp_dict, _ = await self.async_call_api(
396
+ '/user/api/accountManage/v1/loginByAuthorizeCode4Vesync',
397
+ 'post',
398
+ json_object=request_login,
399
+ )
400
+ if resp_dict is None:
401
+ raise VeSyncAPIResponseError('Error receiving response to login request')
402
+ try:
403
+ response_model = ResponseLoginModel.from_dict(resp_dict)
404
+ if not isinstance(response_model.result, RespLoginTokenResultModel):
405
+ raise VeSyncAPIResponseError(
406
+ 'Error receiving response to login request -'
407
+ 'result is not RespLoginTokenResultModel'
408
+ )
409
+ if response_model.code != 0:
410
+ error_info = ErrorCodes.get_error_info(resp_dict.get('code'))
411
+
412
+ # Handle cross region error by retrying login with new region
413
+ if error_info.error_type == ErrorTypes.CROSS_REGION:
414
+ result = response_model.result
415
+ self.country_code = result.countryCode
416
+ return await self._login_token(region_change_token=result.bizToken)
417
+ resp_message = resp_dict.get('msg')
418
+ if resp_message is not None:
419
+ error_info.message = f'{error_info.message} ({resp_message})'
420
+ msg = f'Error receiving response to login request - {error_info.message}'
421
+ raise VeSyncLoginError(msg)
422
+
423
+ result = response_model.result
424
+ if not isinstance(result, RespLoginTokenResultModel):
425
+ raise VeSyncAPIResponseError(
426
+ 'Error receiving response to login request -'
427
+ ' result is not RespLoginTokenResultModel'
428
+ )
429
+
430
+ self._token = result.token
431
+ self._account_id = result.accountID
432
+ self.country_code = result.countryCode
433
+ self.enabled = True
434
+ logger.debug('Login successful')
435
+
436
+ except (MissingField, UnserializableDataError) as exc:
437
+ logger.debug('Error parsing login response: %s', exc)
438
+ raise VeSyncAPIResponseError(
439
+ 'Error receiving response to login request'
440
+ ) from exc
441
+
442
+ async def update(self) -> None:
443
+ """Fetch updated information about devices and new device list.
444
+
445
+ Pulls devices list from VeSync and instantiates any new devices. Devices
446
+ are stored in the instance attributes `outlets`, `switches`, `fans`, and
447
+ `bulbs`. The `_device_list` attribute is a dictionary of these attributes.
448
+ """
449
+ if not self.enabled:
450
+ logger.error('Not logged in to VeSync')
451
+ return
452
+ await self.get_devices()
453
+
454
+ await self.update_all_devices()
455
+
456
+ async def update_all_devices(self) -> None:
457
+ """Run `get_details()` for each device and update state."""
458
+ logger.debug('Start updating the device details one by one')
459
+ update_tasks = [
460
+ asyncio.create_task(device.update()) for device in self._device_container
461
+ ]
462
+ done, _ = await asyncio.wait(update_tasks, return_when=asyncio.ALL_COMPLETED)
463
+ for task in done:
464
+ exc = task.exception()
465
+ if exc is not None and isinstance(exc, VeSyncError):
466
+ logger.debug('Error updating device: %s', exc)
467
+
468
+ async def __aenter__(self) -> Self:
469
+ """Asynchronous context manager enter."""
470
+ return self
471
+
472
+ async def __aexit__(self, *exec_info: object) -> None:
473
+ """Asynchronous context manager exit."""
474
+ if self.session and self._close_session:
475
+ logger.debug('Closing session, exiting context manager')
476
+ await self.session.close()
477
+ return
478
+ logger.debug('Session not closed, exiting context manager')
479
+
480
+ async def async_call_api(
481
+ self,
482
+ api: str,
483
+ method: str,
484
+ json_object: dict | None | DataClassORJSONMixin = None,
485
+ headers: dict | None = None,
486
+ ) -> tuple[dict | None, int | None]:
487
+ """Make API calls by passing endpoint, header and body.
488
+
489
+ api argument is appended to `API_BASE_URL`.
490
+ Raises VeSyncRateLimitError if API returns a rate limit error.
491
+
492
+ Args:
493
+ api (str): Endpoint to call with `API_BASE_URL`.
494
+ method (str): HTTP method to use.
495
+ json_object (dict | RequestBaseModel): JSON object to send in body.
496
+ headers (dict): Headers to send with request.
497
+
498
+ Returns:
499
+ tuple[dict | None, int]: Response and status code. Attempts to parse
500
+ response as JSON, if not possible returns None.
501
+
502
+ Raises:
503
+ VeSyncAPIStatusCodeError: If API returns an error status code.
504
+ VeSyncRateLimitError: If API returns a rate limit error.
505
+ VeSyncServerError: If API returns a server error.
506
+ VeSyncTokenError: If API returns an authentication error.
507
+ ClientResponseError: If API returns a client response error.
508
+
509
+ Note:
510
+ Future releases will require the `json_object` argument to be a dataclass,
511
+ instead of dictionary.
512
+ """
513
+ if self.session is None:
514
+ self.session = ClientSession()
515
+ self._close_session = True
516
+ response = None
517
+ status_code = None
518
+ if isinstance(json_object, DataClassORJSONMixin):
519
+ req_dict = json_object.to_dict()
520
+ elif isinstance(json_object, dict):
521
+ req_dict = json_object
522
+ else:
523
+ req_dict = None
524
+ try:
525
+ async with self.session.request(
526
+ method,
527
+ url=self._api_base_url_for_current_region() + api,
528
+ json=req_dict,
529
+ headers=headers,
530
+ raise_for_status=False,
531
+ ) as response:
532
+ status_code = response.status
533
+ resp_bytes = await response.read()
534
+ if status_code != STATUS_OK:
535
+ LibraryLogger.log_api_status_error(
536
+ logger,
537
+ request_body=req_dict,
538
+ response=response,
539
+ response_bytes=resp_bytes,
540
+ )
541
+ raise VeSyncAPIStatusCodeError(str(status_code))
542
+
543
+ LibraryLogger.log_api_call(logger, response, resp_bytes, req_dict)
544
+ resp_dict = Helpers.try_json_loads(resp_bytes)
545
+ if isinstance(resp_dict, dict):
546
+ error_info = ErrorCodes.get_error_info(resp_dict.get('code'))
547
+ if resp_dict.get('msg') is not None:
548
+ error_info.message = f'{error_info.message} ({resp_dict["msg"]})'
549
+ raise_api_errors(error_info)
550
+
551
+ return resp_dict, status_code
552
+
553
+ except ClientResponseError as e:
554
+ LibraryLogger.log_api_exception(logger, exception=e, request_body=req_dict)
555
+ raise
556
+
557
+ def _api_base_url_for_current_region(self) -> str:
558
+ """Retrieve the API base url for the current region.
559
+
560
+ At this point, only two different URLs exist: One for `EU` region
561
+ (for all EU countries), and one for all others
562
+ (currently `US`, `CA`, `MX`, `JP` - also used as a fallback).
563
+
564
+ If `API_BASE_URL` is set, it will take precedence over the determined URL.
565
+ """
566
+ if self.country_code in NON_EU_REGIONS:
567
+ return API_BASE_URL_US
568
+ return API_BASE_URL_EU
569
+
570
+ def _update_fw_version(self, info_list: list[FirmwareDeviceItemModel]) -> bool:
571
+ """Update device firmware versions from API response."""
572
+ if not info_list:
573
+ logger.debug('No devices found in firmware response')
574
+ return False
575
+ update_dict = {}
576
+ for device in info_list:
577
+ if not device.firmUpdateInfos:
578
+ if device.code != 0:
579
+ logger.debug(
580
+ 'Device %s has error code %s with message: %s',
581
+ device.deviceName,
582
+ device.code,
583
+ device.msg,
584
+ )
585
+ else:
586
+ logger.debug(
587
+ 'Device %s has no firmware updates available', device.deviceName
588
+ )
589
+ continue
590
+ for update_info in device.firmUpdateInfos:
591
+ update_dict[device.deviceCid] = (
592
+ update_info.currentVersion,
593
+ update_info.latestVersion,
594
+ )
595
+ if update_info.isMainFw is True:
596
+ break
597
+ for device_obj in self._device_container:
598
+ if device_obj.cid in update_dict:
599
+ device_obj.latest_firm_version = update_dict[device_obj.cid][1]
600
+ device_obj.current_firm_version = update_dict[device_obj.cid][0]
601
+ return True
602
+
603
+ async def check_firmware(self) -> bool:
604
+ """Check for firmware updates for all devices.
605
+
606
+ This method will check for firmware updates for all devices in the
607
+ device container. It will call the `get_firmware_update()` method on
608
+ each device and log the results.
609
+ """
610
+ if len(self._device_container) == 0:
611
+ logger.debug('No devices to check for firmware updates')
612
+ return False
613
+ body_fields = [
614
+ field.name
615
+ for field in fields(RequestFirmwareModel)
616
+ if field.default_factory is MISSING and field.default is MISSING
617
+ ]
618
+ body = Helpers.get_class_attributes(self, body_fields)
619
+ body['cidList'] = [device.cid for device in self._device_container]
620
+ resp_dict, _ = await self.async_call_api(
621
+ '/cloud/v2/deviceManaged/getFirmwareUpdateInfoList',
622
+ 'post',
623
+ json_object=RequestFirmwareModel(**body),
624
+ )
625
+ if resp_dict is None:
626
+ raise VeSyncAPIResponseError(
627
+ 'Error receiving response to firmware update request'
628
+ )
629
+ resp_model = ResponseFirmwareModel.from_dict(resp_dict)
630
+ if resp_model.code != 0:
631
+ error_info = ErrorCodes.get_error_info(resp_model.code)
632
+ resp_message = resp_model.msg
633
+ if resp_message is not None:
634
+ error_info.message = f'{error_info.message} ({resp_message})'
635
+ logger.debug('Error in firmware update response: %s', error_info.message)
636
+ return False
637
+ info_list = resp_model.result.cidFwInfoList
638
+ return self._update_fw_version(info_list)