pyvesync 3.0.0rc3__tar.gz → 3.1.0__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 (59) hide show
  1. {pyvesync-3.0.0rc3/src/pyvesync.egg-info → pyvesync-3.1.0}/PKG-INFO +57 -4
  2. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/README.md +56 -2
  3. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/pyproject.toml +24 -2
  4. pyvesync-3.1.0/src/pyvesync/auth.py +424 -0
  5. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/base_devices/fan_base.py +1 -2
  6. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/base_devices/vesyncbasedevice.py +4 -12
  7. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/const.py +5 -1
  8. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/device_map.py +8 -2
  9. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/devices/vesynckitchen.py +8 -2
  10. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/devices/vesyncpurifier.py +14 -2
  11. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/purifier_models.py +1 -1
  12. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/utils/colors.py +3 -1
  13. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/utils/helpers.py +5 -2
  14. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/vesync.py +105 -160
  15. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/vesynchome.py +4 -2
  16. {pyvesync-3.0.0rc3 → pyvesync-3.1.0/src/pyvesync.egg-info}/PKG-INFO +57 -4
  17. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync.egg-info/SOURCES.txt +1 -0
  18. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync.egg-info/requires.txt +0 -1
  19. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/LICENSE +0 -0
  20. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/MANIFEST.in +0 -0
  21. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/requirements.txt +0 -0
  22. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/setup.cfg +0 -0
  23. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/setup.py +0 -0
  24. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/__init__.py +0 -0
  25. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/base_devices/__init__.py +0 -0
  26. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/base_devices/bulb_base.py +0 -0
  27. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/base_devices/fryer_base.py +0 -0
  28. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/base_devices/humidifier_base.py +0 -0
  29. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/base_devices/outlet_base.py +0 -0
  30. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/base_devices/purifier_base.py +0 -0
  31. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/base_devices/switch_base.py +0 -0
  32. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/base_devices/thermostat_base.py +0 -0
  33. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/device_container.py +0 -0
  34. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/devices/__init__.py +0 -0
  35. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/devices/vesyncbulb.py +0 -0
  36. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/devices/vesyncfan.py +0 -0
  37. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/devices/vesynchumidifier.py +0 -0
  38. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/devices/vesyncoutlet.py +0 -0
  39. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/devices/vesyncswitch.py +0 -0
  40. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/devices/vesyncthermostat.py +0 -0
  41. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/__init__.py +0 -0
  42. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/base_models.py +0 -0
  43. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/bulb_models.py +0 -0
  44. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/bypass_models.py +0 -0
  45. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/fan_models.py +0 -0
  46. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/fryer_models.py +0 -0
  47. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/home_models.py +0 -0
  48. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/humidifier_models.py +0 -0
  49. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/outlet_models.py +0 -0
  50. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/switch_models.py +0 -0
  51. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/thermostat_models.py +0 -0
  52. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/vesync_models.py +0 -0
  53. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/utils/__init__.py +0 -0
  54. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/utils/device_mixins.py +0 -0
  55. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/utils/enum_utils.py +0 -0
  56. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/utils/errors.py +0 -0
  57. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/utils/logs.py +0 -0
  58. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync.egg-info/dependency_links.txt +0 -0
  59. {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/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.0rc3
3
+ Version: 3.1.0
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>
@@ -17,7 +17,6 @@ Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
18
  Requires-Dist: aiohttp>=3.8.1
19
19
  Requires-Dist: mashumaro[orjson]>=3.13.1
20
- Requires-Dist: python-dateutil>=2.8.1
21
20
  Provides-Extra: dev
22
21
  Requires-Dist: pytest; extra == "dev"
23
22
  Requires-Dist: pytest-cov; extra == "dev"
@@ -193,13 +192,14 @@ The VeSync signature is:
193
192
  VeSync(
194
193
  username: str,
195
194
  password: str,
195
+ country_code: str = DEFAULT_COUNTRY_CODE, # US
196
196
  session: ClientSession | None = None,
197
197
  time_zone: str = DEFAULT_TZ # America/New_York
198
+ debug: bool = False,
199
+ redact: bool = True,
198
200
  )
199
201
  ```
200
202
 
201
- The VeSync class no longer accepts a `debug` or `redact` argument. To set debug the library set `manager.debug = True` to the instance and `manager.redact = True`.
202
-
203
203
  ### Product Types
204
204
 
205
205
  There is a new nomenclature for product types that defines the device class. The
@@ -346,6 +346,59 @@ async def main():
346
346
  device.display()
347
347
 
348
348
 
349
+ if __name__ == "__main__":
350
+ asyncio.run(main())
351
+ ```
352
+
353
+ If you want to reuse your token and account_id between runs. The `VeSync.auth` object holds the credentials and helper methods to save and load credentials.
354
+
355
+ ```python
356
+ import asyncio
357
+ from pyvesync import VeSync
358
+ from pyvesync.logs import VeSyncLoginError
359
+
360
+ # VeSync is an asynchronous context manager
361
+ # VeSync(username, password, debug=False, redact=True, session=None)
362
+
363
+ async def main():
364
+ async with VeSync("user", "password") as manager:
365
+
366
+ # If credentials are stored in a file, it can be loaded
367
+ # the default location is ~/.vesync_token
368
+ await manager.load_credentials_from_file()
369
+ # or the file path can be passed
370
+ await manager.load_credentials_from_file("/path/to/token_file")
371
+
372
+ # Or credentials can be passed directly
373
+ manager.set_credentials("your_token", "your_account_id")
374
+
375
+ # No login needed
376
+ # await manager.login()
377
+
378
+ # To store credentials to a file after login
379
+ await manager.save_credentials() # Saves to default location ~/.vesync_token
380
+ # or pass a file path
381
+ await manager.save_credentials("/path/to/token_file")
382
+
383
+ # Output Credentials as JSON String
384
+ await manager.output_credentials()
385
+
386
+ await manager.update()
387
+
388
+ # Acts as a set of device instances
389
+ device_container = manager.devices
390
+
391
+ outlets = device_container.outlets # List of outlet instances
392
+ outlet = outlets[0]
393
+ await outlet.update()
394
+ await outlet.turn_off()
395
+ outlet.display()
396
+
397
+ # Iterate of entire device list
398
+ for devices in device_container:
399
+ device.display()
400
+
401
+
349
402
  if __name__ == "__main__":
350
403
  asyncio.run(main())
351
404
  ```
@@ -146,13 +146,14 @@ The VeSync signature is:
146
146
  VeSync(
147
147
  username: str,
148
148
  password: str,
149
+ country_code: str = DEFAULT_COUNTRY_CODE, # US
149
150
  session: ClientSession | None = None,
150
151
  time_zone: str = DEFAULT_TZ # America/New_York
152
+ debug: bool = False,
153
+ redact: bool = True,
151
154
  )
152
155
  ```
153
156
 
154
- The VeSync class no longer accepts a `debug` or `redact` argument. To set debug the library set `manager.debug = True` to the instance and `manager.redact = True`.
155
-
156
157
  ### Product Types
157
158
 
158
159
  There is a new nomenclature for product types that defines the device class. The
@@ -299,6 +300,59 @@ async def main():
299
300
  device.display()
300
301
 
301
302
 
303
+ if __name__ == "__main__":
304
+ asyncio.run(main())
305
+ ```
306
+
307
+ If you want to reuse your token and account_id between runs. The `VeSync.auth` object holds the credentials and helper methods to save and load credentials.
308
+
309
+ ```python
310
+ import asyncio
311
+ from pyvesync import VeSync
312
+ from pyvesync.logs import VeSyncLoginError
313
+
314
+ # VeSync is an asynchronous context manager
315
+ # VeSync(username, password, debug=False, redact=True, session=None)
316
+
317
+ async def main():
318
+ async with VeSync("user", "password") as manager:
319
+
320
+ # If credentials are stored in a file, it can be loaded
321
+ # the default location is ~/.vesync_token
322
+ await manager.load_credentials_from_file()
323
+ # or the file path can be passed
324
+ await manager.load_credentials_from_file("/path/to/token_file")
325
+
326
+ # Or credentials can be passed directly
327
+ manager.set_credentials("your_token", "your_account_id")
328
+
329
+ # No login needed
330
+ # await manager.login()
331
+
332
+ # To store credentials to a file after login
333
+ await manager.save_credentials() # Saves to default location ~/.vesync_token
334
+ # or pass a file path
335
+ await manager.save_credentials("/path/to/token_file")
336
+
337
+ # Output Credentials as JSON String
338
+ await manager.output_credentials()
339
+
340
+ await manager.update()
341
+
342
+ # Acts as a set of device instances
343
+ device_container = manager.devices
344
+
345
+ outlets = device_container.outlets # List of outlet instances
346
+ outlet = outlets[0]
347
+ await outlet.update()
348
+ await outlet.turn_off()
349
+ outlet.display()
350
+
351
+ # Iterate of entire device list
352
+ for devices in device_container:
353
+ device.display()
354
+
355
+
302
356
  if __name__ == "__main__":
303
357
  asyncio.run(main())
304
358
  ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyvesync"
7
- version = "3.0.0rc3"
7
+ version = "3.1.0"
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"
@@ -23,7 +23,6 @@ classifiers = [
23
23
  dependencies = [
24
24
  "aiohttp>=3.8.1",
25
25
  "mashumaro[orjson]>=3.13.1",
26
- "python-dateutil>=2.8.1"
27
26
  ]
28
27
 
29
28
  [project.urls]
@@ -70,6 +69,29 @@ norecursedirs = ["*.egg-info", ".git", ".tox", "dist", "build", "venv"]
70
69
  pythonpath = "src"
71
70
  log_cli = true
72
71
 
72
+ [tool.coverage.run]
73
+ branch = true
74
+ omit = [
75
+ "test/*",
76
+ "src/tests/*",
77
+ "testing_scripts/*"
78
+ ]
79
+
80
+ [tool.coverage.report]
81
+ exclude_also = [
82
+ 'pragma: no cover',
83
+ 'if self\\.debug',
84
+ "def __repr__",
85
+ "def __str__",
86
+ "def __eq__",
87
+ "def __hash__",
88
+ "def firmware_update",
89
+ "def get_config",
90
+ "raise AssertionError",
91
+ "raise NotImplementedError",
92
+ "@(abc\\.)?abstractmethod",
93
+ ]
94
+
73
95
  [tool.pylint.MAIN]
74
96
  persistent = false
75
97
  ignore-paths = ["^test\\.*$"]
@@ -0,0 +1,424 @@
1
+ """VeSync Authentication Module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ import orjson
11
+ from mashumaro.exceptions import MissingField, UnserializableDataError
12
+
13
+ from pyvesync.const import DEFAULT_REGION, NON_EU_COUNTRY_CODES
14
+ from pyvesync.models.vesync_models import (
15
+ RequestGetTokenModel,
16
+ RequestLoginTokenModel,
17
+ RespGetTokenResultModel,
18
+ RespLoginTokenResultModel,
19
+ ResponseLoginModel,
20
+ )
21
+ from pyvesync.utils.errors import (
22
+ ErrorCodes,
23
+ ErrorTypes,
24
+ VeSyncAPIResponseError,
25
+ VeSyncLoginError,
26
+ VeSyncServerError,
27
+ )
28
+
29
+ if TYPE_CHECKING:
30
+ from pyvesync.vesync import VeSync
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ class VeSyncAuth:
36
+ """VeSync Authentication Manager.
37
+
38
+ Handles login, token management, and persistent storage of authentication
39
+ credentials for VeSync API access.
40
+ """
41
+
42
+ __slots__ = (
43
+ '_account_id',
44
+ '_country_code',
45
+ '_current_region',
46
+ '_password',
47
+ '_token',
48
+ '_token_file_path',
49
+ '_username',
50
+ 'manager',
51
+ )
52
+
53
+ def __init__(
54
+ self,
55
+ manager: VeSync,
56
+ username: str,
57
+ password: str,
58
+ country_code: str = DEFAULT_REGION,
59
+ ) -> None:
60
+ """Initialize VeSync Authentication Manager.
61
+
62
+ Args:
63
+ manager: VeSync manager instance for API calls
64
+ username: VeSync account username (email)
65
+ password: VeSync account password
66
+ country_code: Country code in ISO 3166 Alpha-2 format
67
+ current_region: Current region code - determines API base URL
68
+ token_file_path: Path to store/load authentication token
69
+
70
+ Note:
71
+ Either username/password or token/account_id must be provided.
72
+ If token_file_path is provided, credentials will be saved/loaded
73
+ automatically.
74
+ """
75
+ self.manager = manager
76
+ self._username = username
77
+ self._password = password
78
+ self._token: str | None = None
79
+ self._account_id: str | None = None
80
+ self._country_code = country_code.upper()
81
+ self._current_region = self._country_code_to_region()
82
+ self._token_file_path: Path | None = None
83
+
84
+ def _country_code_to_region(self) -> str:
85
+ """Convert country code to region string for API use."""
86
+ if self._country_code in NON_EU_COUNTRY_CODES:
87
+ return 'US'
88
+ return 'EU'
89
+
90
+ @property
91
+ def token(self) -> str:
92
+ """Return VeSync API token."""
93
+ if self._token is None:
94
+ raise AttributeError(
95
+ 'Token not set, run VeSync.login or VeSync.set_token method'
96
+ )
97
+ return self._token
98
+
99
+ @property
100
+ def account_id(self) -> str:
101
+ """Return VeSync account ID."""
102
+ if self._account_id is None:
103
+ raise AttributeError(
104
+ 'Account ID not set, run VeSync.login or VeSync.set_token method'
105
+ )
106
+ return self._account_id
107
+
108
+ @property
109
+ def country_code(self) -> str:
110
+ """Return country code."""
111
+ return self._country_code
112
+
113
+ @country_code.setter
114
+ def country_code(self, value: str) -> None:
115
+ """Set country code."""
116
+ self._country_code = value.upper()
117
+
118
+ @property
119
+ def current_region(self) -> str:
120
+ """Return current region."""
121
+ return self._current_region
122
+
123
+ @property
124
+ def is_authenticated(self) -> bool:
125
+ """Check if user is authenticated."""
126
+ return self._token is not None and self._account_id is not None
127
+
128
+ def set_credentials(
129
+ self,
130
+ token: str,
131
+ account_id: str,
132
+ country_code: str,
133
+ region: str,
134
+ ) -> None:
135
+ """Set authentication credentials.
136
+
137
+ Args:
138
+ token: Authentication token
139
+ account_id: Account ID
140
+ country_code: Country code in ISO 3166 Alpha-2 format
141
+ region: Current region code
142
+ """
143
+ self._token = token
144
+ self._account_id = account_id
145
+ self._country_code = country_code.upper()
146
+ self._current_region = region
147
+
148
+ async def load_credentials_from_file(
149
+ self, file_path: str | Path | None = None
150
+ ) -> bool:
151
+ """Load credentials from token file if path is set.
152
+
153
+ If no path is provided, it will try to load from the users home directory and
154
+ then the current working directory.
155
+ """
156
+ locations = [Path.home() / '.vesync_auth', Path.cwd() / '.vesync_auth']
157
+ file_path_object: Path | None = None
158
+ if file_path is None:
159
+ for location in locations:
160
+ if location.exists():
161
+ file_path_object = location
162
+ break
163
+ elif isinstance(file_path, str):
164
+ file_path_object = Path(file_path)
165
+ else:
166
+ file_path_object = Path(file_path)
167
+ if not file_path_object or not file_path_object.exists():
168
+ logger.debug('No token file found to load credentials')
169
+ return False
170
+ try:
171
+ data = await asyncio.to_thread(
172
+ Path(file_path_object).read_text, encoding='utf-8'
173
+ )
174
+ data = orjson.loads(data)
175
+ self._token = data['token']
176
+ self._account_id = data['account_id']
177
+ self._country_code = data['country_code'].upper()
178
+ self._current_region = data['current_region']
179
+ logger.debug('Credentials loaded from file: %s', file_path)
180
+ except orjson.JSONDecodeError as exc:
181
+ logger.warning('Failed to load credentials from file: %s', exc)
182
+ return False
183
+ if self._token is None or self._account_id is None:
184
+ logger.debug('Incomplete credentials in token file')
185
+ self.manager.enabled = False
186
+ return False
187
+ self.manager.enabled = True
188
+ return True
189
+
190
+ def output_credentials(self) -> str | None:
191
+ """Output current credentials as JSON string."""
192
+ if not self.is_authenticated:
193
+ logger.debug('No credentials to output, not authenticated')
194
+ return None
195
+ credentials = {
196
+ 'token': self._token,
197
+ 'account_id': self._account_id,
198
+ 'country_code': self._country_code,
199
+ 'current_region': self._current_region,
200
+ }
201
+ try:
202
+ return orjson.dumps(credentials).decode('utf-8')
203
+ except orjson.JSONEncodeError as exc:
204
+ logger.warning('Failed to serialize credentials: %s', exc)
205
+ return None
206
+
207
+ async def save_credentials_to_file(self, file_path: str | Path | None = None) -> None:
208
+ """Save authentication credentials to file."""
209
+ if file_path is not None:
210
+ file_path_object = Path(file_path)
211
+ elif self._token_file_path is not None:
212
+ file_path_object = self._token_file_path
213
+ else:
214
+ logger.debug('No token file path set, saving to default location')
215
+ file_path_object = Path.home() / '.vesync_auth'
216
+ if not self.is_authenticated:
217
+ logger.debug('No credentials to save, not authenticated')
218
+ return
219
+ credentials = {
220
+ 'token': self._token,
221
+ 'account_id': self._account_id,
222
+ 'country_code': self._country_code,
223
+ }
224
+ try:
225
+ data = orjson.dumps(credentials).decode('utf-8')
226
+ await asyncio.to_thread(file_path_object.write_text, data, encoding='utf-8')
227
+ logger.debug('Credentials saved to file: %s', file_path_object)
228
+ except (orjson.JSONEncodeError, OSError) as exc:
229
+ logger.warning('Failed to save credentials to file: %s', exc)
230
+
231
+ def clear_credentials(self) -> None:
232
+ """Clear all stored credentials."""
233
+ self._token = None
234
+ self._account_id = None
235
+
236
+ # Remove token file if it exists
237
+ if self._token_file_path and self._token_file_path.exists():
238
+ try:
239
+ self._token_file_path.unlink()
240
+ logger.debug('Token file deleted: %s', self._token_file_path)
241
+ except OSError as exc:
242
+ logger.warning('Failed to delete token file: %s', exc)
243
+
244
+ async def login(self) -> bool:
245
+ """Log into VeSync server using username/password or existing token.
246
+
247
+ Returns:
248
+ True if login successful, False otherwise
249
+
250
+ Raises:
251
+ VeSyncLoginError: If login fails due to invalid credentials
252
+ VeSyncAPIResponseError: If API response is invalid
253
+ VeSyncServerError: If server returns an error
254
+ """
255
+ # Attempt username/password login
256
+ if self._username and self._password:
257
+ return await self._login_with_credentials()
258
+
259
+ raise VeSyncLoginError(
260
+ 'No valid authentication method available. '
261
+ 'Provide either username/password or valid token/account_id.'
262
+ )
263
+
264
+ async def _login_with_credentials(self) -> bool:
265
+ """Login using username and password.
266
+
267
+ Returns:
268
+ True if login successful
269
+
270
+ Raises:
271
+ VeSyncLoginError: If login fails
272
+ VeSyncAPIResponseError: If API response is invalid
273
+ """
274
+ if not isinstance(self._username, str) or len(self._username) == 0:
275
+ raise VeSyncLoginError('Username must be a non-empty string')
276
+
277
+ if not isinstance(self._password, str) or len(self._password) == 0:
278
+ raise VeSyncLoginError('Password must be a non-empty string')
279
+
280
+ try:
281
+ # Step 1: Get authorization code
282
+ auth_code = await self._get_authorization_code()
283
+
284
+ # Step 2: Exchange authorization code for token
285
+ await self._exchange_authorization_code(auth_code)
286
+ logger.debug('Login successful for user: %s', self._username)
287
+ except (VeSyncLoginError, VeSyncAPIResponseError, VeSyncServerError):
288
+ raise
289
+ except Exception as exc:
290
+ msg = f'Unexpected error during login: {exc}'
291
+ raise VeSyncLoginError(msg) from exc
292
+ return True
293
+
294
+ async def _get_authorization_code(self) -> str:
295
+ """Get authorization code using username and password.
296
+
297
+ Returns:
298
+ Authorization code
299
+
300
+ Raises:
301
+ VeSyncAPIResponseError: If API response is invalid
302
+ VeSyncLoginError: If authentication fails
303
+ """
304
+ request_auth = RequestGetTokenModel(
305
+ email=self._username, # type: ignore[arg-type]
306
+ method='authByPWDOrOTM',
307
+ password=self._password, # type: ignore[arg-type]
308
+ )
309
+
310
+ resp_dict, _ = await self.manager.async_call_api(
311
+ '/globalPlatform/api/accountAuth/v1/authByPWDOrOTM',
312
+ 'post',
313
+ json_object=request_auth,
314
+ )
315
+
316
+ if resp_dict is None:
317
+ raise VeSyncAPIResponseError('Error receiving response to auth request')
318
+
319
+ if resp_dict.get('code') != 0:
320
+ error_info = ErrorCodes.get_error_info(resp_dict.get('code'))
321
+ resp_message = resp_dict.get('msg')
322
+
323
+ if resp_message is not None:
324
+ error_info.message = f'{error_info.message} ({resp_message})'
325
+
326
+ msg = f'Authentication failed - {error_info.message}'
327
+ raise VeSyncLoginError(msg)
328
+
329
+ try:
330
+ response_model = ResponseLoginModel.from_dict(resp_dict)
331
+ except UnserializableDataError as exc:
332
+ logger.debug('Error parsing auth response: %s', exc)
333
+ raise VeSyncAPIResponseError('Error parsing authentication response') from exc
334
+ except MissingField as exc:
335
+ logger.debug('Error parsing auth response: %s', exc)
336
+ raise VeSyncAPIResponseError('Error parsing authentication response') from exc
337
+
338
+ result = response_model.result
339
+ if not isinstance(result, RespGetTokenResultModel):
340
+ raise VeSyncAPIResponseError('Invalid authentication response format')
341
+ self._account_id = result.accountID
342
+ return result.authorizeCode
343
+
344
+ async def _exchange_authorization_code(
345
+ self,
346
+ auth_code: str,
347
+ region_change_token: str | None = None,
348
+ ) -> None:
349
+ """Exchange authorization code for access token.
350
+
351
+ Args:
352
+ auth_code: Authorization code from first auth step
353
+ region_change_token: Token for region change (retry scenario)
354
+
355
+ Raises:
356
+ VeSyncLoginError: If login fails
357
+ VeSyncAPIResponseError: If API response is invalid
358
+ """
359
+ request_login = RequestLoginTokenModel(
360
+ method='loginByAuthorizeCode4Vesync',
361
+ authorizeCode=auth_code,
362
+ bizToken=region_change_token,
363
+ userCountryCode=self._country_code,
364
+ regionChange='lastRegion' if region_change_token else None,
365
+ )
366
+
367
+ resp_dict, _ = await self.manager.async_call_api(
368
+ '/user/api/accountManage/v1/loginByAuthorizeCode4Vesync',
369
+ 'post',
370
+ json_object=request_login,
371
+ )
372
+
373
+ if resp_dict is None:
374
+ raise VeSyncAPIResponseError('Error receiving response to login request')
375
+
376
+ try:
377
+ response_model = ResponseLoginModel.from_dict(resp_dict)
378
+
379
+ if not isinstance(response_model.result, RespLoginTokenResultModel):
380
+ raise VeSyncAPIResponseError('Invalid login response format')
381
+
382
+ if response_model.code != 0:
383
+ error_info = ErrorCodes.get_error_info(resp_dict.get('code'))
384
+
385
+ # Handle cross region error by retrying with new region
386
+ if error_info.error_type == ErrorTypes.CROSS_REGION:
387
+ result = response_model.result
388
+ self._country_code = result.countryCode
389
+ self._current_region = result.currentRegion
390
+ logger.debug(
391
+ 'Cross-region error, retrying with country: %s',
392
+ self._country_code,
393
+ )
394
+ return await self._exchange_authorization_code(
395
+ auth_code, region_change_token=result.bizToken
396
+ )
397
+
398
+ resp_message = resp_dict.get('msg')
399
+ if resp_message is not None:
400
+ error_info.message = f'{error_info.message} ({resp_message})'
401
+
402
+ msg = f'Login failed - {error_info.message}'
403
+
404
+ # Raise appropriate exception based on error type
405
+ if error_info.error_type == ErrorTypes.SERVER_ERROR:
406
+ raise VeSyncServerError(msg)
407
+ raise VeSyncLoginError(msg)
408
+
409
+ result = response_model.result
410
+ self._token = result.token
411
+ self._account_id = result.accountID
412
+ self._country_code = result.countryCode
413
+
414
+ except (MissingField, UnserializableDataError) as exc:
415
+ logger.debug('Error parsing login response: %s', exc)
416
+ raise VeSyncAPIResponseError('Error parsing login response') from exc
417
+
418
+ def __repr__(self) -> str:
419
+ """Return string representation of VeSyncAuth."""
420
+ return (
421
+ f'VeSyncAuth(username={self._username!r}, '
422
+ f'country_code={self._country_code!r}, '
423
+ f'authenticated={self.is_authenticated})'
424
+ )
@@ -327,8 +327,7 @@ class VeSyncFanBase(VeSyncBaseToggleDevice):
327
327
  """Toggle mute on/off.
328
328
 
329
329
  Parameters:
330
- toggle : bool
331
- True to turn mute on, False to turn off
330
+ toggle (bool): True to turn mute on, False to turn off
332
331
 
333
332
  Returns:
334
333
  bool : True if successful, False if not
@@ -5,12 +5,11 @@ from __future__ import annotations
5
5
  import inspect
6
6
  import logging
7
7
  from abc import ABC, abstractmethod
8
+ from datetime import UTC
8
9
  from datetime import datetime as dt
9
10
  from typing import TYPE_CHECKING, Any, Generic, TypeVar
10
- from zoneinfo import ZoneInfo
11
11
 
12
12
  import orjson
13
- from typing_extensions import deprecated
14
13
 
15
14
  from pyvesync.const import ConnectionStatus, DeviceStatus
16
15
 
@@ -284,11 +283,6 @@ class VeSyncBaseDevice(ABC, Generic[VS_STATE_T]):
284
283
  if state:
285
284
  self.state.display()
286
285
 
287
- @deprecated('Use to_json() instead')
288
- def displayJSON(self, state: bool = True, indent: bool = True) -> str: # pylint: disable=invalid-name
289
- """Return JSON details for device. - Deprecated use to_json()."""
290
- return self.to_json(state, indent)
291
-
292
286
  def to_json(self, state: bool = True, indent: bool = True) -> str:
293
287
  """Print JSON API string for device details.
294
288
 
@@ -449,7 +443,7 @@ class DeviceState:
449
443
  device (VeSyncBaseDevice): Device object.
450
444
  device_status (str): Device status.
451
445
  features (dict): Features of device.
452
- last_update_ts (int): Last update timestamp of device, defaults to None.
446
+ last_update_ts (int): Last update timestamp in UTC, defaults to None.
453
447
 
454
448
  Methods:
455
449
  update_ts: Update last update timestamp.
@@ -508,10 +502,8 @@ class DeviceState:
508
502
  )
509
503
 
510
504
  def update_ts(self) -> None:
511
- """Update last update timestamp."""
512
- self.last_update_ts = int(
513
- dt.now(tz=ZoneInfo(self.device.manager.time_zone)).timestamp()
514
- )
505
+ """Update last update timestamp as UTC timestamp."""
506
+ self.last_update_ts = int(dt.now(tz=UTC).timestamp())
515
507
 
516
508
  @staticmethod
517
509
  def __predicate(attr: Any) -> bool: # noqa: ANN401