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.
- pyvesync/__init__.py +5 -0
- pyvesync/base_devices/__init__.py +35 -0
- pyvesync/base_devices/bulb_base.py +330 -0
- pyvesync/base_devices/fan_base.py +392 -0
- pyvesync/base_devices/fryer_base.py +68 -0
- pyvesync/base_devices/humidifier_base.py +462 -0
- pyvesync/base_devices/outlet_base.py +327 -0
- pyvesync/base_devices/purifier_base.py +525 -0
- pyvesync/base_devices/switch_base.py +271 -0
- pyvesync/base_devices/thermostat_base.py +283 -0
- pyvesync/base_devices/vesyncbasedevice.py +572 -0
- pyvesync/const.py +825 -0
- pyvesync/device_container.py +373 -0
- pyvesync/device_map.py +1172 -0
- pyvesync/devices/__init__.py +1 -0
- pyvesync/devices/vesyncbulb.py +1126 -0
- pyvesync/devices/vesyncfan.py +233 -0
- pyvesync/devices/vesynchumidifier.py +808 -0
- pyvesync/devices/vesynckitchen.py +651 -0
- pyvesync/devices/vesyncoutlet.py +954 -0
- pyvesync/devices/vesyncpurifier.py +990 -0
- pyvesync/devices/vesyncswitch.py +415 -0
- pyvesync/devices/vesyncthermostat.py +171 -0
- pyvesync/models/__init__.py +11 -0
- pyvesync/models/base_models.py +129 -0
- pyvesync/models/bulb_models.py +197 -0
- pyvesync/models/bypass_models.py +289 -0
- pyvesync/models/fan_models.py +47 -0
- pyvesync/models/fryer_models.py +34 -0
- pyvesync/models/home_models.py +169 -0
- pyvesync/models/humidifier_models.py +202 -0
- pyvesync/models/outlet_models.py +220 -0
- pyvesync/models/purifier_models.py +261 -0
- pyvesync/models/switch_models.py +101 -0
- pyvesync/models/thermostat_models.py +134 -0
- pyvesync/models/vesync_models.py +439 -0
- pyvesync/utils/__init__.py +1 -0
- pyvesync/utils/colors.py +250 -0
- pyvesync/utils/device_mixins.py +362 -0
- pyvesync/utils/enum_utils.py +26 -0
- pyvesync/utils/errors.py +867 -0
- pyvesync/utils/helpers.py +700 -0
- pyvesync/utils/logs.py +525 -0
- pyvesync/vesync.py +638 -0
- pyvesync/vesynchome.py +152 -0
- pyvesync-3.0.0.dist-info/METADATA +567 -0
- pyvesync-3.0.0.dist-info/RECORD +50 -0
- pyvesync-3.0.0.dist-info/WHEEL +5 -0
- pyvesync-3.0.0.dist-info/licenses/LICENSE +21 -0
- 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)
|