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.
- {pyvesync-3.0.0rc3/src/pyvesync.egg-info → pyvesync-3.1.0}/PKG-INFO +57 -4
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/README.md +56 -2
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/pyproject.toml +24 -2
- pyvesync-3.1.0/src/pyvesync/auth.py +424 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/base_devices/fan_base.py +1 -2
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/base_devices/vesyncbasedevice.py +4 -12
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/const.py +5 -1
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/device_map.py +8 -2
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/devices/vesynckitchen.py +8 -2
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/devices/vesyncpurifier.py +14 -2
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/purifier_models.py +1 -1
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/utils/colors.py +3 -1
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/utils/helpers.py +5 -2
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/vesync.py +105 -160
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/vesynchome.py +4 -2
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0/src/pyvesync.egg-info}/PKG-INFO +57 -4
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync.egg-info/SOURCES.txt +1 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync.egg-info/requires.txt +0 -1
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/LICENSE +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/MANIFEST.in +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/requirements.txt +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/setup.cfg +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/setup.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/__init__.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/base_devices/__init__.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/base_devices/bulb_base.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/base_devices/fryer_base.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/base_devices/humidifier_base.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/base_devices/outlet_base.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/base_devices/purifier_base.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/base_devices/switch_base.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/base_devices/thermostat_base.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/device_container.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/devices/__init__.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/devices/vesyncbulb.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/devices/vesyncfan.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/devices/vesynchumidifier.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/devices/vesyncoutlet.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/devices/vesyncswitch.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/devices/vesyncthermostat.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/__init__.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/base_models.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/bulb_models.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/bypass_models.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/fan_models.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/fryer_models.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/home_models.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/humidifier_models.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/outlet_models.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/switch_models.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/thermostat_models.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/models/vesync_models.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/utils/__init__.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/utils/device_mixins.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/utils/enum_utils.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/utils/errors.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync/utils/logs.py +0 -0
- {pyvesync-3.0.0rc3 → pyvesync-3.1.0}/src/pyvesync.egg-info/dependency_links.txt +0 -0
- {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
|
|
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
|
|
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 :
|
|
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
|
|
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
|