pyvesync 3.0.0b2__tar.gz → 3.0.0b3__tar.gz

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 (58) hide show
  1. {pyvesync-3.0.0b2/src/pyvesync.egg-info → pyvesync-3.0.0b3}/PKG-INFO +1 -1
  2. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/pyproject.toml +1 -1
  3. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/const.py +13 -4
  4. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/models/base_models.py +4 -0
  5. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/models/vesync_models.py +88 -9
  6. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/utils/errors.py +6 -0
  7. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/utils/logs.py +21 -12
  8. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/vesync.py +130 -22
  9. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3/src/pyvesync.egg-info}/PKG-INFO +1 -1
  10. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/LICENSE +0 -0
  11. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/MANIFEST.in +0 -0
  12. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/README.md +0 -0
  13. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/requirements.txt +0 -0
  14. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/setup.cfg +0 -0
  15. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/setup.py +0 -0
  16. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/__init__.py +0 -0
  17. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/base_devices/__init__.py +0 -0
  18. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/base_devices/bulb_base.py +0 -0
  19. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/base_devices/fan_base.py +0 -0
  20. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/base_devices/fryer_base.py +0 -0
  21. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/base_devices/humidifier_base.py +0 -0
  22. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/base_devices/outlet_base.py +0 -0
  23. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/base_devices/purifier_base.py +0 -0
  24. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/base_devices/switch_base.py +0 -0
  25. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/base_devices/thermostat_base.py +0 -0
  26. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/base_devices/vesyncbasedevice.py +0 -0
  27. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/device_container.py +0 -0
  28. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/device_map.py +0 -0
  29. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/devices/__init__.py +0 -0
  30. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/devices/vesyncbulb.py +0 -0
  31. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/devices/vesyncfan.py +0 -0
  32. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/devices/vesynchumidifier.py +0 -0
  33. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/devices/vesynckitchen.py +0 -0
  34. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/devices/vesyncoutlet.py +0 -0
  35. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/devices/vesyncpurifier.py +0 -0
  36. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/devices/vesyncswitch.py +0 -0
  37. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/devices/vesyncthermostat.py +0 -0
  38. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/models/__init__.py +0 -0
  39. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/models/bulb_models.py +0 -0
  40. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/models/bypass_models.py +0 -0
  41. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/models/fan_models.py +0 -0
  42. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/models/fryer_models.py +0 -0
  43. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/models/home_models.py +0 -0
  44. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/models/humidifier_models.py +0 -0
  45. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/models/outlet_models.py +0 -0
  46. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/models/purifier_models.py +0 -0
  47. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/models/switch_models.py +0 -0
  48. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/models/thermostat_models.py +0 -0
  49. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/utils/__init__.py +0 -0
  50. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/utils/colors.py +0 -0
  51. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/utils/device_mixins.py +0 -0
  52. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/utils/enum_utils.py +0 -0
  53. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/utils/helpers.py +0 -0
  54. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync/vesynchome.py +0 -0
  55. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync.egg-info/SOURCES.txt +0 -0
  56. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync.egg-info/dependency_links.txt +0 -0
  57. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync.egg-info/requires.txt +0 -0
  58. {pyvesync-3.0.0b2 → pyvesync-3.0.0b3}/src/pyvesync.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyvesync
3
- Version: 3.0.0b2
3
+ Version: 3.0.0b3
4
4
  Summary: pyvesync is a library to manage Etekcity Devices, Cosori Air Fryers, and Levoit Air Purifiers run on the VeSync app.
5
5
  Author: Mark Perdue
6
6
  Author-email: Joe Trabulsy <webdjoe@gmail.com>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyvesync"
7
- version = "3.0.0b2"
7
+ version = "3.0.0b3"
8
8
  description = "pyvesync is a library to manage Etekcity Devices, Cosori Air Fryers, and Levoit Air Purifiers run on the VeSync app."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -4,7 +4,8 @@ All device states and information are defined by Enums in this module.
4
4
 
5
5
  Attributes:
6
6
  DEFAULT_LANGUAGE (str): Default language for the VeSync app.
7
- API_BASE_URL (str): Base URL for the VeSync API.
7
+ API_BASE_URL (str): Base URL for the VeSync API. If not specified,
8
+ a region-specific API URL is automatically selected.
8
9
  API_TIMEOUT (int): Timeout for API requests.
9
10
  USER_AGENT (str): User agent for API requests.
10
11
  DEFAULT_TZ (str): Default timezone for VeSync devices, updated by API after
@@ -12,6 +13,8 @@ Attributes:
12
13
  DEFAULT_REGION (str): Default region for VeSync devices,
13
14
  updated by API when retrieving devices.
14
15
  APP_VERSION (str): Version of the VeSync app.
16
+ APP_ID (str): ID of the app. VeSync uses a random 8-letter string, but any non-empty
17
+ string works.
15
18
  PHONE_BRAND (str): Brand of the phone used to login to the VeSync app.
16
19
  PHONE_OS (str): Operating system of the phone used to login to the VeSync app.
17
20
  MOBILE_ID (str): Unique identifier for the phone used to login to the VeSync app.
@@ -20,23 +23,28 @@ Attributes:
20
23
  BYPASS_HEADER_UA (str): Bypass header user agent
21
24
  TERMINAL_ID (str): Unique identifier for new API calls
22
25
  """
23
- from random import randint
26
+ from random import randint, choices
24
27
  from uuid import uuid4
28
+ import string
25
29
  from enum import StrEnum, IntEnum, Enum
26
30
  from types import MappingProxyType
27
31
  from pyvesync.utils.enum_utils import IntEnumMixin
28
32
 
29
33
  DEFAULT_LANGUAGE = "en"
30
- API_BASE_URL = "https://smartapi.vesync.com"
34
+ API_BASE_URL = None # Global URL (non-EU regions): "https://smartapi.vesync.com"
31
35
  # If device is out of reach, the cloud api sends a timeout response after 7 seconds,
32
36
  # using 8 here so there is time enough to catch that message
37
+ API_BASE_URL_US = "https://smartapi.vesync.com"
38
+ API_BASE_URL_EU = "https://smartapi.vesync.eu"
39
+ NON_EU_REGIONS = ['US', 'CA', 'MX', 'JP']
33
40
  API_TIMEOUT = 8
34
41
  USER_AGENT = (
35
42
  "VeSync/3.2.39 (com.etekcity.vesyncPlatform; build:5; iOS 15.5.0) Alamofire/5.2.1"
36
43
  )
37
44
  DEFAULT_TZ = "America/New_York"
38
45
  DEFAULT_REGION = "US"
39
- APP_VERSION = "5.5.60"
46
+ APP_VERSION = "5.6.60"
47
+ APP_ID = ''.join(choices(string.ascii_lowercase + string.digits, k=8)) # noqa: S311
40
48
  PHONE_BRAND = "SM N9005"
41
49
  PHONE_OS = "Android"
42
50
  # MOBILE_ID = "1234567890123456"
@@ -45,6 +53,7 @@ USER_TYPE = "1"
45
53
  BYPASS_APP_V = f"VeSync {APP_VERSION}"
46
54
  BYPASS_HEADER_UA = "okhttp/3.12.1"
47
55
  TERMINAL_ID = '2' + str(uuid4()).replace('-', '')
56
+ CLIENT_TYPE = "vesyncApp"
48
57
 
49
58
 
50
59
  # Generic Constants
@@ -27,6 +27,7 @@ from mashumaro.config import BaseConfig
27
27
  from pyvesync.const import (
28
28
  USER_TYPE,
29
29
  APP_VERSION,
30
+ APP_ID,
30
31
  PHONE_BRAND,
31
32
  PHONE_OS,
32
33
  MOBILE_ID,
@@ -35,6 +36,7 @@ from pyvesync.const import (
35
36
  DEFAULT_TZ,
36
37
  BYPASS_HEADER_UA,
37
38
  TERMINAL_ID,
39
+ CLIENT_TYPE
38
40
  )
39
41
 
40
42
 
@@ -86,6 +88,8 @@ class DefaultValues:
86
88
  _call_number: ClassVar[int] = 0
87
89
  userType: str = USER_TYPE
88
90
  appVersion: str = APP_VERSION
91
+ clientType: str = CLIENT_TYPE
92
+ appId: str = APP_ID
89
93
  phoneBrand: str = PHONE_BRAND
90
94
  phoneOS: str = PHONE_OS
91
95
  mobileId: str = MOBILE_ID
@@ -23,8 +23,8 @@ from pyvesync.models.base_models import (
23
23
 
24
24
 
25
25
  @dataclass
26
- class RequestLoginModel(RequestBaseModel):
27
- """Request model for login."""
26
+ class RequestAuthModel(RequestBaseModel):
27
+ """Request model for requesting auth token (used for login)."""
28
28
  # Arguments to set
29
29
  email: str
30
30
  method: str
@@ -32,21 +32,22 @@ class RequestLoginModel(RequestBaseModel):
32
32
  # default values
33
33
  acceptLanguage: str = DefaultValues.acceptLanguage
34
34
  accountID: str = ''
35
+ authProtocolType: str = "generic"
35
36
  clientInfo: str = DefaultValues.phoneBrand
36
- clientType: str = "vesyncApp"
37
+ clientType: str = DefaultValues.clientType
37
38
  clientVersion: str = f"VeSync {DefaultValues.appVersion}"
38
39
  debugMode: bool = False
39
40
  osInfo: str = DefaultValues.phoneOS
40
41
  terminalId: str = DefaultValues.terminalId
41
42
  timeZone: str = DefaultValues.timeZone
42
43
  token: str = ''
43
- userCountryCode: str = ''
44
- devToken: str = ''
45
- userType: str = DefaultValues.userType
44
+ userCountryCode: str = DefaultValues.userCountryCode
45
+ appID: str = DefaultValues.appId
46
+ sourceAppID: str = DefaultValues.appId
46
47
  traceId: str = field(default_factory=DefaultValues.newTraceId)
47
48
 
48
49
  def __post_init__(self) -> None:
49
- """Set the method field."""
50
+ """Hash the password field."""
50
51
  self.password = self.hash_password(self.password)
51
52
 
52
53
  @staticmethod
@@ -55,6 +56,50 @@ class RequestLoginModel(RequestBaseModel):
55
56
  return hashlib.md5(string.encode('utf-8')).hexdigest() # noqa: S324
56
57
 
57
58
 
59
+ @dataclass
60
+ class IntRespAuthResultModel(ResponseBaseModel):
61
+ """Model for the 'result' field in auth response with authorizeCode and account ID.
62
+
63
+ This class is referenced by the `ResponseAuthModel` class.
64
+ """
65
+ accountID: str
66
+ authorizeCode: str
67
+
68
+
69
+ @dataclass
70
+ class RequestLoginModel(RequestBaseModel):
71
+ """Request model for login."""
72
+ # Arguments to set
73
+ method: str
74
+ authorizeCode: str | None
75
+ # default values
76
+ acceptLanguage: str = DefaultValues.acceptLanguage
77
+ accountID: str = ''
78
+ clientInfo: str = DefaultValues.phoneBrand
79
+ clientType: str = DefaultValues.clientType
80
+ clientVersion: str = f"VeSync {DefaultValues.appVersion}"
81
+ debugMode: bool = False
82
+ emailSubscriptions: bool = False
83
+ osInfo: str = DefaultValues.phoneOS
84
+ terminalId: str = DefaultValues.terminalId
85
+ timeZone: str = DefaultValues.timeZone
86
+ token: str = ""
87
+ bizToken: str | None = None
88
+ regionChange: str | None = None
89
+ userCountryCode: str = DefaultValues.userCountryCode
90
+ traceId: str = field(default_factory=DefaultValues.newTraceId)
91
+
92
+ def __post_serialize__(self, d: dict[Any, Any]) -> dict[Any, Any]:
93
+ """Remove null keys."""
94
+ if d['regionChange'] is None:
95
+ d.pop('regionChange')
96
+ if d['authorizeCode'] is None:
97
+ d.pop('authorizeCode')
98
+ if d['bizToken'] is None:
99
+ d.pop('bizToken')
100
+ return d
101
+
102
+
58
103
  @dataclass
59
104
  class IntRespLoginResultModel(ResponseBaseModel):
60
105
  """Model for the 'result' field in login response containing token and account ID.
@@ -65,6 +110,40 @@ class IntRespLoginResultModel(ResponseBaseModel):
65
110
  acceptLanguage: str
66
111
  countryCode: str
67
112
  token: str
113
+ bizToken: str = ''
114
+ currentRegion: str = ''
115
+
116
+
117
+ # @dataclass
118
+ # class ResponseAuthModel(ResponseCodeModel):
119
+ # """Model for the auth response.
120
+
121
+ # Inherits from `BaseResultModel`. The `BaseResultModel` class provides the
122
+ # defaults "code" and "msg" fields for the response.
123
+
124
+ # Attributes:
125
+ # result: ResponseAuthResultModel
126
+ # The inner model for the 'result' field in the auth response.
127
+
128
+ # Examples:
129
+ # ```python
130
+ # a = {
131
+ # "code": 0,
132
+ # "msg": "success",
133
+ # "stacktrace": null,
134
+ # "module": null,
135
+ # "traceId": "123456",
136
+ # "result": {
137
+ # "accountID": "123456",
138
+ # "authorizeCode": "abcdef1234567890"
139
+ # }
140
+ # }
141
+ # b = ResponseAuthModel.from_dict(a)
142
+ # account_id = b.result.accountId
143
+ # authorizeCode = b.result.authorizeCode
144
+ # ```
145
+ # """
146
+ # result: IntRespAuthResultModel
68
147
 
69
148
 
70
149
  @dataclass
@@ -91,14 +170,14 @@ class ResponseLoginModel(ResponseCodeModel):
91
170
  "acceptLanguage": "en",
92
171
  "countryCode": "US",
93
172
  "token": "abcdef1234567890"
94
- }
173
+ }
95
174
  }
96
175
  b = ResponseLoginModel.from_dict(a)
97
176
  account_id = b.result.accountId
98
177
  token = b.result.token
99
178
  ```
100
179
  """
101
- result: IntRespLoginResultModel
180
+ result: IntRespLoginResultModel | IntRespAuthResultModel
102
181
 
103
182
 
104
183
  @dataclass
@@ -97,6 +97,7 @@ class ErrorTypes(StrEnum):
97
97
  UNKNOWN_ERROR = "unknown_error"
98
98
  TOKEN_ERROR = "token_error"
99
99
  BAD_RESPONSE = "bad_response"
100
+ CROSS_REGION = "cross_region"
100
101
 
101
102
 
102
103
  class ErrorCodes:
@@ -135,6 +136,11 @@ class ErrorCodes:
135
136
 
136
137
  errors: MappingProxyType[str, ResponseInfo] = MappingProxyType(
137
138
  {
139
+ "-11260022": ResponseInfo(
140
+ "CROSS_REGION_ERROR",
141
+ ErrorTypes.CROSS_REGION,
142
+ "Cross region error",
143
+ ),
138
144
  "11": ResponseInfo(
139
145
  "DEVICE_OFFLINE",
140
146
  ErrorTypes.DEVICE_OFFLINE,
@@ -100,6 +100,7 @@ class LibraryLogger:
100
100
  their values with '##_REDACTED_##'. The keys that are redacted include:
101
101
 
102
102
  - token
103
+ - authorizeCode
103
104
  - password
104
105
  - email
105
106
  - tk
@@ -130,6 +131,7 @@ class LibraryLogger:
130
131
  r'(?<=uuid":\s")|'
131
132
  r'(?<=cid":\s")|'
132
133
  r"(?<=token\s)|"
134
+ r"(?<=authorizeCode\s)|"
133
135
  r"(?<=account_id\s))"
134
136
  r'[^"\s]+'
135
137
  ),
@@ -182,7 +184,9 @@ class LibraryLogger:
182
184
 
183
185
  @staticmethod
184
186
  def configure_logger(
185
- level: str | int = logging.INFO, file_name: str | None = None
187
+ level: str | int = logging.INFO,
188
+ file_name: str | Path | None = None,
189
+ std_out: bool = True,
186
190
  ) -> None:
187
191
  """Configure pyvesync library logger with a specific log level.
188
192
 
@@ -191,6 +195,7 @@ class LibraryLogger:
191
195
  in form of enum `logging.DEBUG` or string `DEBUG`.
192
196
  file_name (str | None): The name of the file to log to. If None,
193
197
  logs will only be printed to the console.
198
+ std_out (bool): If True, logs will be printed to standard output.
194
199
 
195
200
  Note:
196
201
  This method configures the pyvesync base logger and prevents
@@ -203,18 +208,22 @@ class LibraryLogger:
203
208
  if root_logger.handlers:
204
209
  for handler in root_logger.handlers:
205
210
  root_logger.removeHandler(handler)
206
- handler = logging.StreamHandler()
211
+
207
212
  formatter = logging.Formatter(
208
213
  fmt="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
209
214
  datefmt="%Y-%m-%d %H:%M:%S",
210
215
  )
211
- handler.setFormatter(formatter)
212
- root_logger.addHandler(handler)
213
- if file_name:
214
- if not Path(file_name).is_absolute():
215
- file_name_path = Path(file_name).joinpath(Path.cwd(), file_name)
216
- else:
217
- file_name_path = Path(file_name)
216
+ if std_out is True:
217
+ str_handler = logging.StreamHandler()
218
+ str_handler.setFormatter(formatter)
219
+ root_logger.addHandler(str_handler)
220
+ if isinstance(file_name, str):
221
+ file_name_path = Path(file_name).resolve()
222
+ elif isinstance(file_name, Path):
223
+ file_name_path = file_name.resolve()
224
+ else:
225
+ file_name_path = None
226
+ if file_name_path:
218
227
  file_handler = logging.FileHandler(file_name_path)
219
228
  file_handler.setFormatter(formatter)
220
229
  root_logger.addHandler(file_handler)
@@ -274,11 +283,11 @@ class LibraryLogger:
274
283
  if dif:
275
284
  msg += "\n\n-------------------------------------"
276
285
  msg += "\n Expected Fields:"
277
- msg += f"\n({", ".join(field_f)})"
286
+ msg += f"\n({', '.join(field_f)})"
278
287
  msg += "\n Response Fields:"
279
- msg += f"\n({", ".join(resp_f)})"
288
+ msg += f"\n({', '.join(resp_f)})"
280
289
  msg += "\n Missing Fields:"
281
- msg += f"\n({", ".join(dif)})"
290
+ msg += f"\n({', '.join(dif)})"
282
291
 
283
292
  msg += "\n\n Full Response:"
284
293
  msg += f"\n{orjson.dumps(resp_dict, option=orjson.OPT_INDENT_2).decode('utf-8')}"
@@ -3,21 +3,31 @@
3
3
  from __future__ import annotations
4
4
  import logging
5
5
  import asyncio
6
+ from pathlib import Path
6
7
  from typing import Self
7
8
  from dataclasses import fields, MISSING
8
9
  from aiohttp import ClientSession
9
10
  from aiohttp.client_exceptions import ClientResponseError
10
11
  from mashumaro.mixins.orjson import DataClassORJSONMixin
12
+ from mashumaro.exceptions import UnserializableDataError, MissingField
11
13
 
12
14
  from pyvesync.utils.helpers import Helpers
13
- from pyvesync.const import API_BASE_URL, DEFAULT_REGION
15
+ from pyvesync.const import (
16
+ API_BASE_URL_US,
17
+ API_BASE_URL_EU,
18
+ DEFAULT_REGION,
19
+ NON_EU_REGIONS,
20
+ )
14
21
  from pyvesync.device_container import DeviceContainer, DeviceContainerInstance
15
22
  from pyvesync.utils.logs import LibraryLogger
16
23
  from pyvesync.models.vesync_models import (
17
24
  RequestDeviceListModel,
18
25
  ResponseDeviceListModel,
26
+ RequestAuthModel,
27
+ IntRespAuthResultModel,
19
28
  RequestLoginModel,
20
29
  ResponseLoginModel,
30
+ IntRespLoginResultModel,
21
31
  RequestFirmwareModel,
22
32
  ResponseFirmwareModel,
23
33
  FirmwareDeviceItemModel
@@ -67,6 +77,7 @@ class VeSync: # pylint: disable=function-redefined
67
77
  def __init__(self,
68
78
  username: str,
69
79
  password: str,
80
+ country_code: str = DEFAULT_REGION,
70
81
  session: ClientSession | None = None,
71
82
  time_zone: str = DEFAULT_TZ) -> None:
72
83
  """Initialize VeSync Manager.
@@ -83,6 +94,11 @@ class VeSync: # pylint: disable=function-redefined
83
94
  Args:
84
95
  username (str): VeSync account username (usually email address)
85
96
  password (str): VeSync account password
97
+ country_code (str): VeSync account country in ISO 3166 Alpha-2 format.
98
+ By default, the account region is detected automatically at the login step
99
+ If your account country is different from the default `US`,
100
+ a second login attempt may be necessary - in this case
101
+ you should specify the country directly to speed up the login process.
86
102
  session (ClientSession): aiohttp client session for
87
103
  API calls, by default None
88
104
  time_zone (str): Time zone for device from IANA database, by default
@@ -130,7 +146,7 @@ class VeSync: # pylint: disable=function-redefined
130
146
  self.password: str = password
131
147
  self._token: str | None = None
132
148
  self._account_id: str | None = None
133
- self.country_code: str = DEFAULT_REGION
149
+ self.country_code: str = country_code.upper()
134
150
  self._verbose: bool = False
135
151
  self.time_zone: str = time_zone
136
152
  self.language: str = 'en'
@@ -207,10 +223,15 @@ class VeSync: # pylint: disable=function-redefined
207
223
  LibraryLogger.shouldredact = False
208
224
  self._redact = new_flag
209
225
 
210
- def log_to_file(self, filename: str) -> None:
211
- """Log to file and enable debug logging."""
226
+ def log_to_file(self, filename: str | Path, std_out: bool = True) -> None:
227
+ """Log to file and enable debug logging.
228
+
229
+ Args:
230
+ filename (str | Path): The name of the file to log to.
231
+ std_out (bool): If False, logs will not print to std out.
232
+ """
212
233
  self.debug = True
213
- LibraryLogger.configure_logger(logging.DEBUG, file_name=filename)
234
+ LibraryLogger.configure_logger(logging.DEBUG, file_name=filename, std_out=std_out)
214
235
  logger.debug('Logging to file: %s', filename)
215
236
 
216
237
  def process_devices(self, dev_list_resp: ResponseDeviceListModel) -> bool:
@@ -300,38 +321,112 @@ class VeSync: # pylint: disable=function-redefined
300
321
  if not isinstance(self.username, str) or len(self.username) == 0 \
301
322
  or not isinstance(self.password, str) or len(self.password) == 0:
302
323
  raise VesyncLoginError('Username and password must be specified')
303
- request_login = RequestLoginModel(
324
+ request_auth = RequestAuthModel(
304
325
  email=self.username,
305
- method='appLoginV3',
326
+ method='authByPWDOrOTM',
306
327
  password=self.password,
307
328
  )
308
329
  resp_dict, _ = await self.async_call_api(
309
- '/user/api/accountManage/v3/appLoginV3', 'post',
310
- json_object=request_login
330
+ '/globalPlatform/api/accountAuth/v1/authByPWDOrOTM', 'post',
331
+ json_object=request_auth
311
332
  )
312
333
  if resp_dict is None:
313
- raise VeSyncAPIResponseError('Error receiving response to login request')
334
+ raise VeSyncAPIResponseError('Error receiving response to auth request')
314
335
  if resp_dict.get('code') == 0:
315
336
  try:
316
337
  response_model = ResponseLoginModel.from_dict(resp_dict)
317
338
  except Exception as exc:
318
- logger.debug('Error parsing login response: %s', exc)
339
+ logger.debug('Error parsing auth response: %s', exc)
319
340
  raise VeSyncAPIResponseError(
320
- 'Error receiving response to login request'
341
+ 'Error receiving response to auth request'
321
342
  ) from exc
322
343
  result = response_model.result
323
- self._token = result.token
324
- self._account_id = result.accountID
325
- self.country_code = result.countryCode
326
- self.enabled = True
327
- logger.debug('Login successful')
328
- return True
344
+ if isinstance(result, IntRespAuthResultModel):
345
+ # If the result is an IntRespAuthResultModel,
346
+ # we need to log in with the auth code
347
+ return await self._login_token(auth_code=result.authorizeCode)
329
348
 
330
349
  error_info = ErrorCodes.get_error_info(resp_dict.get("code"))
331
350
  resp_message = resp_dict.get('msg')
332
351
  if resp_message is not None:
333
352
  error_info.message = f'{error_info.message} ({resp_message})'
334
- raise VeSyncAPIResponseError('Error receiving response to login request')
353
+ raise VeSyncAPIResponseError(
354
+ f"Error receiving response to auth request - {error_info.message}"
355
+ )
356
+
357
+ async def _login_token(
358
+ self,
359
+ auth_code: str | None = None,
360
+ region_change_token: str | None = None,
361
+ ) -> bool: # pylint: disable=W9006 # pylint mult docstring raises
362
+ """Exchanges the authorization code for a token.
363
+
364
+ This completes the login process. If the initial call fails with
365
+ `"login trigger cross region error."`, the region is adjusted and
366
+ another attempt is made.
367
+
368
+ Args:
369
+ auth_code (str): Auth code to use for logging in.
370
+ region_change_token (str): "bizToken" to use when calling this endpoint
371
+ for a second time with a different region.
372
+
373
+ Returns:
374
+ True if login successful, False if not.
375
+
376
+ Raises:
377
+ VeSyncLoginError: If login fails due to invalid username or password.
378
+ VeSyncAPIResponseError: If API response is invalid.
379
+ VeSyncServerError: If server returns an error.
380
+ """
381
+ request_login = RequestLoginModel(
382
+ method='loginByAuthorizeCode4Vesync',
383
+ authorizeCode=auth_code,
384
+ bizToken=region_change_token,
385
+ userCountryCode=self.country_code,
386
+ regionChange="last_region" if region_change_token else None,
387
+ )
388
+ resp_dict, _ = await self.async_call_api(
389
+ '/user/api/accountManage/v1/loginByAuthorizeCode4Vesync', 'post',
390
+ json_object=request_login
391
+ )
392
+ if resp_dict is None:
393
+ raise VeSyncAPIResponseError('Error receiving response to login request')
394
+ try:
395
+ response_model = ResponseLoginModel.from_dict(resp_dict)
396
+ if not isinstance(response_model.result, IntRespLoginResultModel):
397
+ raise VeSyncAPIResponseError(
398
+ "Error receiving response to login request -"
399
+ "result is not IntRespLoginResultModel"
400
+ )
401
+ if response_model.code == 0:
402
+ result = response_model.result
403
+ if not isinstance(result, IntRespLoginResultModel):
404
+ raise VeSyncAPIResponseError(
405
+ "Error receiving response to login request -"
406
+ " result is not IntRespLoginResultModel"
407
+ )
408
+ self._token = result.token
409
+ self._account_id = result.accountID
410
+ self.country_code = result.countryCode
411
+ self.enabled = True
412
+ logger.debug('Login successful')
413
+ return True
414
+ error_info = ErrorCodes.get_error_info(resp_dict.get("code"))
415
+ if error_info.error_type == ErrorTypes.CROSS_REGION: # "cross region error."
416
+ result = response_model.result
417
+ self.country_code = result.countryCode
418
+ return await self._login_token(region_change_token=result.bizToken)
419
+ resp_message = resp_dict.get('msg')
420
+ if resp_message is not None:
421
+ error_info.message = f'{error_info.message} ({resp_message})'
422
+ raise VesyncLoginError(
423
+ f"Error receiving response to login request - {error_info.message}"
424
+ )
425
+ except (MissingField, UnserializableDataError) as exc:
426
+ logger.debug('Error parsing login response: %s', exc)
427
+ raise VeSyncAPIResponseError(
428
+ 'Error receiving response to login request'
429
+ ) from exc
335
430
 
336
431
  async def update(self) -> None:
337
432
  """Fetch updated information about devices and new device list.
@@ -378,11 +473,11 @@ class VeSync: # pylint: disable=function-redefined
378
473
  ) -> tuple[dict | None, int | None]:
379
474
  """Make API calls by passing endpoint, header and body.
380
475
 
381
- api argument is appended to https://smartapi.vesync.com url.
476
+ api argument is appended to `API_BASE_URL`.
382
477
  Raises VeSyncRateLimitError if API returns a rate limit error.
383
478
 
384
479
  Args:
385
- api (str): Endpoint to call with https://smartapi.vesync.com.
480
+ api (str): Endpoint to call with `API_BASE_URL`.
386
481
  method (str): HTTP method to use.
387
482
  json_object (dict | RequestBaseModel): JSON object to send in body.
388
483
  headers (dict): Headers to send with request.
@@ -416,7 +511,7 @@ class VeSync: # pylint: disable=function-redefined
416
511
  try:
417
512
  async with self.session.request(
418
513
  method,
419
- url=API_BASE_URL + api,
514
+ url=self._api_base_url_for_current_region() + api,
420
515
  json=req_dict,
421
516
  headers=headers,
422
517
  raise_for_status=False,
@@ -447,6 +542,19 @@ class VeSync: # pylint: disable=function-redefined
447
542
  LibraryLogger.log_api_exception(logger, exception=e, request_body=req_dict)
448
543
  raise
449
544
 
545
+ def _api_base_url_for_current_region(self) -> str:
546
+ """Retrieve the API base url for the current region.
547
+
548
+ At this point, only two different URLs exist: One for `EU` region
549
+ (for all EU countries), and one for all others
550
+ (currently `US`, `CA`, `MX`, `JP` - also used as a fallback).
551
+
552
+ If `API_BASE_URL` is set, it will take precedence over the determined URL.
553
+ """
554
+ if self.country_code in NON_EU_REGIONS:
555
+ return API_BASE_URL_US
556
+ return API_BASE_URL_EU
557
+
450
558
  def _update_fw_version(self, info_list: list[FirmwareDeviceItemModel]) -> bool:
451
559
  """Update device firmware versions from API response."""
452
560
  if not info_list:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyvesync
3
- Version: 3.0.0b2
3
+ Version: 3.0.0b3
4
4
  Summary: pyvesync is a library to manage Etekcity Devices, Cosori Air Fryers, and Levoit Air Purifiers run on the VeSync app.
5
5
  Author: Mark Perdue
6
6
  Author-email: Joe Trabulsy <webdjoe@gmail.com>
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes