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
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
"""Helper functions for VeSync API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from dataclasses import InitVar, dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
12
|
+
|
|
13
|
+
import orjson
|
|
14
|
+
from mashumaro.exceptions import InvalidFieldValue, MissingField, UnserializableField
|
|
15
|
+
from mashumaro.mixins.orjson import DataClassORJSONMixin
|
|
16
|
+
from typing_extensions import deprecated
|
|
17
|
+
|
|
18
|
+
from pyvesync.const import (
|
|
19
|
+
APP_VERSION,
|
|
20
|
+
BYPASS_HEADER_UA,
|
|
21
|
+
DEFAULT_REGION,
|
|
22
|
+
KELVIN_MAX,
|
|
23
|
+
KELVIN_MIN,
|
|
24
|
+
MOBILE_ID,
|
|
25
|
+
PHONE_BRAND,
|
|
26
|
+
PHONE_OS,
|
|
27
|
+
USER_TYPE,
|
|
28
|
+
ConnectionStatus,
|
|
29
|
+
)
|
|
30
|
+
from pyvesync.utils.errors import ErrorCodes, ErrorTypes, ResponseInfo
|
|
31
|
+
from pyvesync.utils.logs import LibraryLogger
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice
|
|
35
|
+
from pyvesync.vesync import VeSync
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
T = TypeVar('T')
|
|
39
|
+
T_MODEL = TypeVar('T_MODEL', bound=DataClassORJSONMixin)
|
|
40
|
+
|
|
41
|
+
_LOGGER = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
NUMERIC_OPT = float | str | None
|
|
44
|
+
|
|
45
|
+
NUMERIC_STRICT = float | str
|
|
46
|
+
|
|
47
|
+
REQUEST_T = dict[str, Any]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Validators:
|
|
51
|
+
"""Methods to validate input."""
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def validate_range(
|
|
55
|
+
value: NUMERIC_OPT, minimum: NUMERIC_STRICT, maximum: NUMERIC_STRICT
|
|
56
|
+
) -> bool:
|
|
57
|
+
"""Validate number is within range."""
|
|
58
|
+
if value is None:
|
|
59
|
+
return False
|
|
60
|
+
try:
|
|
61
|
+
return float(minimum) <= float(value) <= float(maximum)
|
|
62
|
+
except (ValueError, TypeError):
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def validate_zero_to_hundred(cls, value: NUMERIC_OPT) -> bool:
|
|
67
|
+
"""Validate number is a percentage."""
|
|
68
|
+
return Validators.validate_range(value, 0, 100)
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def validate_hsv(
|
|
72
|
+
cls, hue: NUMERIC_OPT, saturation: NUMERIC_OPT, value: NUMERIC_OPT
|
|
73
|
+
) -> bool:
|
|
74
|
+
"""Validate HSV values."""
|
|
75
|
+
return (
|
|
76
|
+
cls.validate_range(hue, 0, 360)
|
|
77
|
+
and cls.validate_zero_to_hundred(saturation)
|
|
78
|
+
and cls.validate_zero_to_hundred(value)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def validate_rgb(
|
|
83
|
+
cls, red: NUMERIC_OPT, green: NUMERIC_OPT, blue: NUMERIC_OPT
|
|
84
|
+
) -> bool:
|
|
85
|
+
"""Validate RGB values."""
|
|
86
|
+
return all(cls.validate_range(val, 0, 255) for val in (red, green, blue))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Converters:
|
|
90
|
+
"""Helper functions to convert units."""
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def color_temp_kelvin_to_pct(kelvin: int) -> int:
|
|
94
|
+
"""Convert Kelvin to percentage."""
|
|
95
|
+
return int((kelvin - KELVIN_MIN) / (KELVIN_MAX - KELVIN_MIN) * 100)
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def color_temp_pct_to_kelvin(pct: int) -> int:
|
|
99
|
+
"""Convert percentage to Kelvin."""
|
|
100
|
+
return int(KELVIN_MIN + ((pct / 100) * (KELVIN_MAX - KELVIN_MIN)))
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def temperature_kelvin_to_celsius(kelvin: int) -> float:
|
|
104
|
+
"""Convert Kelvin to Celsius."""
|
|
105
|
+
return kelvin - 273.15
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def temperature_celsius_to_kelvin(celsius: float) -> int:
|
|
109
|
+
"""Convert Celsius to Kelvin."""
|
|
110
|
+
return int(celsius + 273.15)
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def temperature_fahrenheit_to_celsius(fahrenheit: float) -> float:
|
|
114
|
+
"""Convert Fahrenheit to Celsius."""
|
|
115
|
+
return (fahrenheit - 32) * 5.0 / 9.0
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def temperature_celsius_to_fahrenheit(celsius: float) -> float:
|
|
119
|
+
"""Convert Celsius to Fahrenheit."""
|
|
120
|
+
return celsius * 9.0 / 5.0 + 32
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Helpers:
|
|
124
|
+
"""VeSync Helper Functions."""
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def model_maker(
|
|
128
|
+
logger: logging.Logger,
|
|
129
|
+
model: type[T_MODEL],
|
|
130
|
+
method_name: str,
|
|
131
|
+
data: dict[str, Any],
|
|
132
|
+
device: VeSyncBaseDevice | None = None,
|
|
133
|
+
) -> T_MODEL | None:
|
|
134
|
+
"""Create a model instance from a dictionary.
|
|
135
|
+
|
|
136
|
+
This method catches common errors that occur when parsing the
|
|
137
|
+
API response and returns None if the data is invalid. Enable debug
|
|
138
|
+
or verbose logging to see more information.
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
logger (logging.Logger): Logger instance.
|
|
143
|
+
model (type[T_MODEL]): Model class to create an instance of.
|
|
144
|
+
method_name (str): Name of the method used in API call.
|
|
145
|
+
device (VeSyncBaseDevice): Instance of VeSyncBaseDevice.
|
|
146
|
+
data (dict[str, Any] | None): Dictionary to create the model from.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
T_MODEL: Instance of the model class.
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
model_instance = model.from_dict(data)
|
|
153
|
+
except (MissingField, UnserializableField, InvalidFieldValue) as err:
|
|
154
|
+
LibraryLogger.log_mashumaro_response_error(
|
|
155
|
+
logger,
|
|
156
|
+
method_name,
|
|
157
|
+
data,
|
|
158
|
+
err,
|
|
159
|
+
device,
|
|
160
|
+
)
|
|
161
|
+
return None
|
|
162
|
+
return model_instance
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def bump_level(level: T | None, levels: list[T]) -> T:
|
|
166
|
+
"""Increment level by one returning to first level if at last.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
level (T | None): Current level.
|
|
170
|
+
levels (list[T]): List of levels.
|
|
171
|
+
"""
|
|
172
|
+
if level in levels:
|
|
173
|
+
idx = levels.index(level)
|
|
174
|
+
if idx < len(levels) - 1:
|
|
175
|
+
return levels[idx + 1]
|
|
176
|
+
return levels[0]
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def try_json_loads(data: str | bytes | None) -> dict | None:
|
|
180
|
+
"""Try to load JSON data.
|
|
181
|
+
|
|
182
|
+
Gracefully handle errors and return None if loading fails.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
data (str | bytes | None): JSON data to load.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
dict | None: Parsed JSON data or None if loading fails.
|
|
189
|
+
"""
|
|
190
|
+
if data is None:
|
|
191
|
+
return None
|
|
192
|
+
try:
|
|
193
|
+
return orjson.loads(data)
|
|
194
|
+
except (orjson.JSONDecodeError, TypeError):
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
@classmethod
|
|
198
|
+
def process_dev_response( # noqa: C901,PLR0912
|
|
199
|
+
cls,
|
|
200
|
+
logger: logging.Logger,
|
|
201
|
+
method_name: str,
|
|
202
|
+
device: VeSyncBaseDevice,
|
|
203
|
+
r_dict: dict | None,
|
|
204
|
+
) -> dict | None:
|
|
205
|
+
"""Process JSON response from Bytes.
|
|
206
|
+
|
|
207
|
+
Parses bytes and checks for errors common to all JSON
|
|
208
|
+
responses, included checking the "code" key for non-zero
|
|
209
|
+
values. Outputs error to passed logger with formatted string
|
|
210
|
+
if an error is found. This also saves the response code information
|
|
211
|
+
to the `device.last_response` attribute.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
logger (logging.Logger): Logger instance.
|
|
215
|
+
method_name (str): Method used in API call.
|
|
216
|
+
r_dict (dict | None): JSON response from API.
|
|
217
|
+
device (VeSyncBaseDevice): Instance of VeSyncBaseDevice.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
dict | None: Parsed JSON response or None if there was an error.
|
|
221
|
+
"""
|
|
222
|
+
device.state.update_ts()
|
|
223
|
+
if r_dict is None:
|
|
224
|
+
logger.error('No response from API for %s', method_name)
|
|
225
|
+
device.last_response = ResponseInfo(
|
|
226
|
+
name='INVALID_RESPONSE',
|
|
227
|
+
error_type=ErrorTypes.BAD_RESPONSE,
|
|
228
|
+
message=f'No response from API for {method_name}',
|
|
229
|
+
)
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
error_code = (
|
|
233
|
+
r_dict.get('error', {}).get('code')
|
|
234
|
+
if 'error' in r_dict
|
|
235
|
+
else r_dict.get('code')
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
new_msg = None
|
|
239
|
+
# Get error codes from nested dictionaries.
|
|
240
|
+
if error_code == 0:
|
|
241
|
+
internal_codes = cls._get_internal_codes(r_dict)
|
|
242
|
+
for code_tuple in internal_codes:
|
|
243
|
+
if code_tuple[0] != 0:
|
|
244
|
+
error_code = code_tuple[0]
|
|
245
|
+
new_msg = code_tuple[1]
|
|
246
|
+
break
|
|
247
|
+
|
|
248
|
+
if isinstance(error_code, int):
|
|
249
|
+
error_int = error_code
|
|
250
|
+
elif isinstance(error_code, str):
|
|
251
|
+
try:
|
|
252
|
+
error_int = int(error_code)
|
|
253
|
+
except ValueError:
|
|
254
|
+
error_int = -999999999
|
|
255
|
+
else:
|
|
256
|
+
error_int = -999999999
|
|
257
|
+
error_info = ErrorCodes.get_error_info(error_int)
|
|
258
|
+
if new_msg is not None:
|
|
259
|
+
if error_info.error_type == ErrorTypes.UNKNOWN_ERROR:
|
|
260
|
+
error_info.message = new_msg
|
|
261
|
+
else:
|
|
262
|
+
error_info.message = f'{error_info.message} - {new_msg}'
|
|
263
|
+
if error_info.device_online is False:
|
|
264
|
+
device.state.connection_status = ConnectionStatus.OFFLINE
|
|
265
|
+
LibraryLogger.log_device_return_code(
|
|
266
|
+
logger,
|
|
267
|
+
method_name,
|
|
268
|
+
device.device_name,
|
|
269
|
+
device.device_type,
|
|
270
|
+
error_int,
|
|
271
|
+
f'{error_info.error_type} - {error_info.name} {error_info.message}',
|
|
272
|
+
)
|
|
273
|
+
device.last_response = error_info
|
|
274
|
+
if error_int != 0:
|
|
275
|
+
return None
|
|
276
|
+
return r_dict
|
|
277
|
+
|
|
278
|
+
@staticmethod
|
|
279
|
+
def get_class_attributes(target_class: object, keys: list[str]) -> dict[str, Any]:
|
|
280
|
+
"""Find matching attributes, static methods, and class methods from list of keys.
|
|
281
|
+
|
|
282
|
+
This function is case insensitive and will remove underscores from the keys before
|
|
283
|
+
comparing them to the class attributes. The provided keys will be returned in the
|
|
284
|
+
same format if found
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
target_class (object): Class to search for attributes
|
|
288
|
+
keys (list[str]): List of keys to search for
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
dict[str, Any]: Dictionary of keys and their values from the class
|
|
292
|
+
"""
|
|
293
|
+
alias_map = {
|
|
294
|
+
'userCountryCode': 'countrycode',
|
|
295
|
+
'deviceId': 'cid',
|
|
296
|
+
'homeTimeZone': 'timezone',
|
|
297
|
+
'configModel': 'configmodule',
|
|
298
|
+
'region': 'countrycode',
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
def normalize_name(name: str) -> str:
|
|
302
|
+
"""Normalize a string by removing underscores and making it lowercase."""
|
|
303
|
+
return re.sub(r'_', '', name).lower()
|
|
304
|
+
|
|
305
|
+
def get_value(attr_name: str) -> str | float | None:
|
|
306
|
+
"""Get value from attribute."""
|
|
307
|
+
attr = getattr(target_class, attr_name)
|
|
308
|
+
try:
|
|
309
|
+
return attr() if callable(attr) else attr # type: ignore[no-any-return]
|
|
310
|
+
except TypeError:
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
result = {}
|
|
314
|
+
normalized_keys = {normalize_name(key): key for key in keys}
|
|
315
|
+
normalized_aliases = [normalize_name(key) for key in alias_map.values()]
|
|
316
|
+
|
|
317
|
+
for attr_name in dir(target_class):
|
|
318
|
+
normalized_attr = normalize_name(attr_name)
|
|
319
|
+
if normalized_attr in normalized_keys:
|
|
320
|
+
attr_val = get_value(attr_name)
|
|
321
|
+
if attr_val is not None:
|
|
322
|
+
result[normalized_keys[normalized_attr]] = attr_val
|
|
323
|
+
if normalized_attr in normalized_aliases:
|
|
324
|
+
attr_val = get_value(attr_name)
|
|
325
|
+
if attr_val is not None:
|
|
326
|
+
key_index = normalized_aliases.index(normalized_attr)
|
|
327
|
+
key_val = list(alias_map.keys())[key_index]
|
|
328
|
+
if key_val in keys:
|
|
329
|
+
result[key_val] = attr_val
|
|
330
|
+
|
|
331
|
+
return result
|
|
332
|
+
|
|
333
|
+
@staticmethod
|
|
334
|
+
def req_legacy_headers(manager: VeSync) -> dict[str, str]:
|
|
335
|
+
"""Build header for legacy api GET requests.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
manager (VeSyncManager): Instance of VeSyncManager.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
dict: Header dictionary for api requests.
|
|
342
|
+
|
|
343
|
+
Examples:
|
|
344
|
+
>>> req_headers(manager)
|
|
345
|
+
{
|
|
346
|
+
'accept-language': 'en',
|
|
347
|
+
'accountId': manager.account_id,
|
|
348
|
+
'appVersion': APP_VERSION,
|
|
349
|
+
'content-type': 'application/json',
|
|
350
|
+
'tk': manager.token,
|
|
351
|
+
'tz': manager.time_zone,
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
"""
|
|
355
|
+
return {
|
|
356
|
+
'accept-language': 'en',
|
|
357
|
+
'accountId': manager.account_id, # type: ignore[dict-item]
|
|
358
|
+
'appVersion': APP_VERSION,
|
|
359
|
+
'content-type': 'application/json',
|
|
360
|
+
'tk': manager.token, # type: ignore[dict-item]
|
|
361
|
+
'tz': manager.time_zone,
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
@staticmethod
|
|
365
|
+
def req_header_bypass() -> dict[str, str]:
|
|
366
|
+
"""Build header for api requests on 'bypass' endpoint.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
dict: Header dictionary for api requests.
|
|
370
|
+
|
|
371
|
+
Examples:
|
|
372
|
+
>>> req_header_bypass()
|
|
373
|
+
{
|
|
374
|
+
'Content-Type': 'application/json; charset=UTF-8',
|
|
375
|
+
'User-Agent': BYPASS_HEADER_UA,
|
|
376
|
+
}
|
|
377
|
+
"""
|
|
378
|
+
return {
|
|
379
|
+
'Content-Type': 'application/json; charset=UTF-8',
|
|
380
|
+
'User-Agent': BYPASS_HEADER_UA,
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
@staticmethod
|
|
384
|
+
def _req_body_base(manager: VeSync) -> dict[str, str]:
|
|
385
|
+
"""Return universal keys for body of api requests.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
manager (VeSyncManager): Instance of VeSyncManager.
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
dict: Body dictionary for api requests.
|
|
392
|
+
|
|
393
|
+
Examples:
|
|
394
|
+
>>> req_body_base(manager)
|
|
395
|
+
{
|
|
396
|
+
'timeZone': manager.time_zone,
|
|
397
|
+
'acceptLanguage': 'en',
|
|
398
|
+
}
|
|
399
|
+
"""
|
|
400
|
+
return {'timeZone': manager.time_zone, 'acceptLanguage': 'en'}
|
|
401
|
+
|
|
402
|
+
@staticmethod
|
|
403
|
+
def _req_body_auth(manager: VeSync) -> REQUEST_T:
|
|
404
|
+
"""Keys for authenticating api requests.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
manager (VeSyncManager): Instance of VeSyncManager.
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
dict: Authentication keys for api requests.
|
|
411
|
+
|
|
412
|
+
Examples:
|
|
413
|
+
>>> req_body_auth(manager)
|
|
414
|
+
{
|
|
415
|
+
'accountID': manager.account_id,
|
|
416
|
+
'token': manager.token,
|
|
417
|
+
}
|
|
418
|
+
"""
|
|
419
|
+
return {'accountID': manager.account_id, 'token': manager.token}
|
|
420
|
+
|
|
421
|
+
@staticmethod
|
|
422
|
+
@deprecated('This is a legacy function and will be removed in a future release.')
|
|
423
|
+
def _req_body_details() -> REQUEST_T:
|
|
424
|
+
"""Detail keys for api requests.
|
|
425
|
+
|
|
426
|
+
This method is deprecated, use `get_class_attributes` instead.
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
dict: Detail keys for api requests.
|
|
430
|
+
|
|
431
|
+
Examples:
|
|
432
|
+
>>> req_body_details()
|
|
433
|
+
{
|
|
434
|
+
'appVersion': APP_VERSION,
|
|
435
|
+
'phoneBrand': PHONE_BRAND,
|
|
436
|
+
'phoneOS': PHONE_OS,
|
|
437
|
+
'traceId': str(int(time.time())),
|
|
438
|
+
}
|
|
439
|
+
"""
|
|
440
|
+
return {
|
|
441
|
+
'appVersion': APP_VERSION,
|
|
442
|
+
'phoneBrand': PHONE_BRAND,
|
|
443
|
+
'phoneOS': PHONE_OS,
|
|
444
|
+
'traceId': str(int(time.time())),
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
@classmethod
|
|
448
|
+
@deprecated('This is a legacy function and will be removed in a future release.')
|
|
449
|
+
def req_body(cls, manager: VeSync, type_: str) -> REQUEST_T: # noqa: C901
|
|
450
|
+
"""Builder for body of api requests.
|
|
451
|
+
|
|
452
|
+
This method is deprecated, use `get_class_attributes` instead.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
manager (VeSyncManager): Instance of VeSyncManager.
|
|
456
|
+
type_ (str): Type of request to build body for.
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
dict: Body dictionary for api requests.
|
|
460
|
+
|
|
461
|
+
Note:
|
|
462
|
+
The body dictionary will be built based on the type of request.
|
|
463
|
+
The type of requests include:
|
|
464
|
+
- login
|
|
465
|
+
- devicestatus
|
|
466
|
+
- devicelist
|
|
467
|
+
- devicedetail
|
|
468
|
+
- energy_week
|
|
469
|
+
- energy_month
|
|
470
|
+
- energy_year
|
|
471
|
+
- bypass
|
|
472
|
+
- bypassV2
|
|
473
|
+
- bypass_config
|
|
474
|
+
"""
|
|
475
|
+
body: REQUEST_T = cls._req_body_base(manager)
|
|
476
|
+
|
|
477
|
+
if type_ == 'login':
|
|
478
|
+
body |= cls._req_body_details()
|
|
479
|
+
body |= {
|
|
480
|
+
'email': manager.username,
|
|
481
|
+
'password': cls.hash_password(manager.password),
|
|
482
|
+
'devToken': '',
|
|
483
|
+
'userType': USER_TYPE,
|
|
484
|
+
'method': 'login',
|
|
485
|
+
}
|
|
486
|
+
return body
|
|
487
|
+
|
|
488
|
+
body |= cls._req_body_auth(manager)
|
|
489
|
+
|
|
490
|
+
if type_ == 'devicestatus':
|
|
491
|
+
return body
|
|
492
|
+
|
|
493
|
+
body |= cls._req_body_details()
|
|
494
|
+
|
|
495
|
+
if type_ == 'devicelist':
|
|
496
|
+
body['method'] = 'devices'
|
|
497
|
+
body['pageNo'] = '1'
|
|
498
|
+
body['pageSize'] = '100'
|
|
499
|
+
|
|
500
|
+
elif type_ == 'devicedetail':
|
|
501
|
+
body['method'] = 'devicedetail'
|
|
502
|
+
body['mobileId'] = MOBILE_ID
|
|
503
|
+
|
|
504
|
+
elif type_ == 'energy_week':
|
|
505
|
+
body['method'] = 'energyweek'
|
|
506
|
+
body['mobileId'] = MOBILE_ID
|
|
507
|
+
|
|
508
|
+
elif type_ == 'energy_month':
|
|
509
|
+
body['method'] = 'energymonth'
|
|
510
|
+
body['mobileId'] = MOBILE_ID
|
|
511
|
+
|
|
512
|
+
elif type_ == 'energy_year':
|
|
513
|
+
body['method'] = 'energyyear'
|
|
514
|
+
body['mobileId'] = MOBILE_ID
|
|
515
|
+
|
|
516
|
+
elif type_ == 'bypass':
|
|
517
|
+
body['method'] = 'bypass'
|
|
518
|
+
|
|
519
|
+
elif type_ == 'bypassV2':
|
|
520
|
+
body['deviceRegion'] = DEFAULT_REGION
|
|
521
|
+
body['method'] = 'bypassV2'
|
|
522
|
+
|
|
523
|
+
elif type_ == 'bypass_config':
|
|
524
|
+
body['method'] = 'firmwareUpdateInfo'
|
|
525
|
+
|
|
526
|
+
return body
|
|
527
|
+
|
|
528
|
+
@staticmethod
|
|
529
|
+
def calculate_hex(hex_string: str) -> float:
|
|
530
|
+
"""Credit for conversion to itsnotlupus/vesync_wsproxy.
|
|
531
|
+
|
|
532
|
+
Hex conversion for legacy outlet power and voltage.
|
|
533
|
+
"""
|
|
534
|
+
hex_conv = hex_string.split(':')
|
|
535
|
+
return (int(hex_conv[0], 16) + int(hex_conv[1], 16)) / 8192
|
|
536
|
+
|
|
537
|
+
@staticmethod
|
|
538
|
+
def hash_password(string: str) -> str:
|
|
539
|
+
"""Encode password."""
|
|
540
|
+
return hashlib.md5(string.encode('utf-8')).hexdigest() # noqa: S324
|
|
541
|
+
|
|
542
|
+
@staticmethod
|
|
543
|
+
def _get_internal_codes(response: dict) -> list[tuple[int, str | None]]:
|
|
544
|
+
"""Get all error codes from nested dictionary.
|
|
545
|
+
|
|
546
|
+
Args:
|
|
547
|
+
response (dict): API response.
|
|
548
|
+
|
|
549
|
+
Returns:
|
|
550
|
+
list[int]: List of error codes.
|
|
551
|
+
"""
|
|
552
|
+
error_keys = ['error', 'code', 'device_error_code', 'errorCode']
|
|
553
|
+
|
|
554
|
+
def extract_all_error_codes(
|
|
555
|
+
key: str, var: dict
|
|
556
|
+
) -> Iterator[tuple[int, str | None]]:
|
|
557
|
+
"""Find all error code keys in nested dictionary."""
|
|
558
|
+
if hasattr(var, 'items'):
|
|
559
|
+
for k, v in var.items():
|
|
560
|
+
if k == key and int(v) != 0:
|
|
561
|
+
msg = var.get('msg') or var.get('result', {}).get('msg')
|
|
562
|
+
yield v, msg
|
|
563
|
+
if isinstance(v, dict):
|
|
564
|
+
yield from extract_all_error_codes(key, v)
|
|
565
|
+
elif isinstance(v, list):
|
|
566
|
+
for item in v:
|
|
567
|
+
yield from extract_all_error_codes(key, item)
|
|
568
|
+
|
|
569
|
+
errors = []
|
|
570
|
+
for error_key in error_keys:
|
|
571
|
+
errors.extend(list(extract_all_error_codes(error_key, response)))
|
|
572
|
+
return errors
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
@dataclass(repr=False)
|
|
576
|
+
class Timer:
|
|
577
|
+
"""Dataclass to hold state of timers.
|
|
578
|
+
|
|
579
|
+
Note:
|
|
580
|
+
This should be used by VeSync device instances to manage internal status,
|
|
581
|
+
does not interact with the VeSync API.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
timer_duration (int): Length of timer in seconds
|
|
585
|
+
action (str): Action to perform when timer is done
|
|
586
|
+
id (int): ID of timer, defaults to 1
|
|
587
|
+
remaining (int): Time remaining on timer in seconds, defaults to None
|
|
588
|
+
_update_time (int): Last updated unix timestamp in seconds, defaults to None
|
|
589
|
+
|
|
590
|
+
Attributes:
|
|
591
|
+
update_time (str): Timestamp of last update
|
|
592
|
+
status (str): Status of timer, one of 'active', 'paused', 'done'
|
|
593
|
+
time_remaining (int): Time remaining on timer in seconds
|
|
594
|
+
running (bool): True if timer is running
|
|
595
|
+
paused (bool): True if timer is paused
|
|
596
|
+
done (bool): True if timer is done
|
|
597
|
+
"""
|
|
598
|
+
|
|
599
|
+
timer_duration: int
|
|
600
|
+
action: str
|
|
601
|
+
id: int = 1
|
|
602
|
+
remaining: InitVar[int | None] = None
|
|
603
|
+
_status: str = field(default='active', init=False, repr=False)
|
|
604
|
+
_remain: int = field(default=0, init=False, repr=False)
|
|
605
|
+
_update_time: int = int(time.time())
|
|
606
|
+
|
|
607
|
+
def __post_init__(self, remaining: int | None) -> None:
|
|
608
|
+
"""Set remaining time if provided."""
|
|
609
|
+
if remaining is not None:
|
|
610
|
+
self._remain = remaining
|
|
611
|
+
else:
|
|
612
|
+
self._remain = self.timer_duration
|
|
613
|
+
|
|
614
|
+
def __repr__(self) -> str:
|
|
615
|
+
"""Return string representation of the Timer object.
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
str: String representation of the Timer object.
|
|
619
|
+
"""
|
|
620
|
+
return (
|
|
621
|
+
f'Timer(id={self.id}, duration={self.timer_duration}, '
|
|
622
|
+
f'status={self.status}, remaining={self.time_remaining})'
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
def update_ts(self) -> None:
|
|
626
|
+
"""Update timestamp."""
|
|
627
|
+
self._update_time = int(time.time())
|
|
628
|
+
|
|
629
|
+
@property
|
|
630
|
+
def status(self) -> str:
|
|
631
|
+
"""Return status of timer."""
|
|
632
|
+
if self._status in ('paused', 'done'):
|
|
633
|
+
return self._status
|
|
634
|
+
if self.time_remaining <= 0:
|
|
635
|
+
self._status = 'done'
|
|
636
|
+
return 'done'
|
|
637
|
+
return 'active'
|
|
638
|
+
|
|
639
|
+
@property
|
|
640
|
+
def time_remaining(self) -> int:
|
|
641
|
+
"""Return remaining seconds."""
|
|
642
|
+
if self._status == 'paused':
|
|
643
|
+
return self._remain
|
|
644
|
+
if self._status == 'done':
|
|
645
|
+
return 0
|
|
646
|
+
|
|
647
|
+
# 'active' state - compute how much time has ticked away
|
|
648
|
+
elapsed = time.time() - self._update_time
|
|
649
|
+
current_remaining = self._remain - elapsed
|
|
650
|
+
|
|
651
|
+
# If we've run out of time, mark it done
|
|
652
|
+
if current_remaining <= 0:
|
|
653
|
+
return 0
|
|
654
|
+
return int(current_remaining)
|
|
655
|
+
|
|
656
|
+
@property
|
|
657
|
+
def running(self) -> bool:
|
|
658
|
+
"""Check if timer is active."""
|
|
659
|
+
return self.time_remaining > 0 and self.status == 'active'
|
|
660
|
+
|
|
661
|
+
@property
|
|
662
|
+
def paused(self) -> bool:
|
|
663
|
+
"""Check if timer is paused."""
|
|
664
|
+
return bool(self.status == 'paused')
|
|
665
|
+
|
|
666
|
+
@property
|
|
667
|
+
def done(self) -> bool:
|
|
668
|
+
"""Check if timer is complete."""
|
|
669
|
+
return bool(self.time_remaining <= 0 or self._status == 'done')
|
|
670
|
+
|
|
671
|
+
def end(self) -> None:
|
|
672
|
+
"""Change status of timer to done."""
|
|
673
|
+
self._status = 'done'
|
|
674
|
+
self._remain = 0
|
|
675
|
+
|
|
676
|
+
def start(self) -> None:
|
|
677
|
+
"""Restart paused timer."""
|
|
678
|
+
if self._status != 'paused':
|
|
679
|
+
return
|
|
680
|
+
self._update_time = int(time.time())
|
|
681
|
+
self._status = 'active'
|
|
682
|
+
|
|
683
|
+
def pause(self) -> None:
|
|
684
|
+
"""Pauses the timer if it's active.
|
|
685
|
+
|
|
686
|
+
Performs the following steps:
|
|
687
|
+
- Calculate the up-to-date remaining time,
|
|
688
|
+
- Update internal counters,
|
|
689
|
+
- Set _status to 'paused'.
|
|
690
|
+
"""
|
|
691
|
+
if self._status == 'active':
|
|
692
|
+
# Update the time_remaining based on elapsed
|
|
693
|
+
current_remaining = self.time_remaining
|
|
694
|
+
if current_remaining <= 0:
|
|
695
|
+
self._status = 'done'
|
|
696
|
+
self._remain = 0
|
|
697
|
+
else:
|
|
698
|
+
self._status = 'paused'
|
|
699
|
+
self._remain = current_remaining
|
|
700
|
+
self._update_time = int(time.time())
|