Skip to content

Commit eb8cecb

Browse files
webdjoecdnninja
andauthored
fix: Token re-authentication and night light model bug (#409)
* fix: data types * Fix tests * Add re-authentication logic for expired tokens * Add Tests --------- Co-authored-by: cdnninja <[email protected]>
1 parent c39408b commit eb8cecb

File tree

11 files changed

+151
-42
lines changed

11 files changed

+151
-42
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "pyvesync"
7-
version = "3.1.1"
7+
version = "3.1.2"
88
description = "pyvesync is a library to manage Etekcity Devices, Cosori Air Fryers, and Levoit Air Purifiers run on the VeSync app."
99
readme = "README.md"
1010
requires-python = ">=3.11"

src/pyvesync/auth.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,15 @@ def set_credentials(
145145
self._country_code = country_code.upper()
146146
self._current_region = region
147147

148+
async def reauthenticate(self) -> bool:
149+
"""Re-authenticate using stored username and password.
150+
151+
Returns:
152+
True if re-authentication successful, False otherwise
153+
"""
154+
self.clear_credentials()
155+
return await self.login()
156+
148157
async def load_credentials_from_file(
149158
self, file_path: str | Path | None = None
150159
) -> bool:

src/pyvesync/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
from pyvesync.utils.enum_utils import IntEnumMixin
3636

37+
MAX_API_REAUTH_RETRIES = 3
3738
DEFAULT_LANGUAGE = 'en'
3839
API_BASE_URL = None # Global URL (non-EU regions): "https://smartapi.vesync.com"
3940
# If device is out of reach, the cloud api sends a timeout response after 7 seconds,

src/pyvesync/devices/vesyncpurifier.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ def _set_purifier_state(self, result: PurifierCoreDetailsResult) -> None:
146146
self.state.pm25 = result.air_quality_value
147147
self.state.set_air_quality_level(result.air_quality)
148148
if result.night_light is not None:
149-
self.state.nightlight_status = DeviceStatus.from_bool(result.night_light)
149+
self.state.nightlight_status = DeviceStatus(result.night_light)
150150

151151
async def get_details(self) -> None:
152152
r_dict = await self.call_bypassv2_api('getPurifierStatus')

src/pyvesync/models/purifier_models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ class PurifierCoreDetailsResult(InnerPurifierBaseResult):
124124
configuration: PurifierCoreDetailsConfig | None = None
125125
extension: dict | None = None
126126
air_quality_value: int | None = None
127-
night_light: bool | None = None
127+
night_light: str | None = None
128128
fan_rotate: str | None = None
129129

130130

src/pyvesync/utils/errors.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -799,9 +799,11 @@ def __init__(self, msg: str) -> None:
799799
class VeSyncTokenError(VeSyncError):
800800
"""Exception raised for VeSync API authentication errors."""
801801

802-
def __init__(self) -> None:
802+
def __init__(self, msg: str | None = None) -> None:
803803
"""Initialize the exception with a message."""
804-
super().__init__('Token expired or invalid - please re-authenticate with login()')
804+
super().__init__(
805+
f'Token expired or invalid - {msg if msg else "Re-authentication required"}'
806+
)
805807

806808

807809
class VeSyncServerError(VeSyncError):
@@ -857,8 +859,6 @@ def raise_api_errors(error_info: ResponseInfo) -> None:
857859
raise VeSyncRateLimitError
858860
case ErrorTypes.AUTHENTICATION:
859861
raise VeSyncLoginError(error_info.message)
860-
case ErrorTypes.TOKEN_ERROR:
861-
raise VeSyncTokenError
862862
case ErrorTypes.SERVER_ERROR:
863863
msg = (
864864
f'{error_info.message} - '

src/pyvesync/vesync.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from pyvesync.const import (
1717
DEFAULT_REGION,
1818
DEFAULT_TZ,
19+
MAX_API_REAUTH_RETRIES,
1920
REGION_API_MAP,
2021
STATUS_OK,
2122
)
@@ -34,6 +35,7 @@
3435
VeSyncAPIStatusCodeError,
3536
VeSyncError,
3637
VeSyncServerError,
38+
VeSyncTokenError,
3739
raise_api_errors,
3840
)
3941
from pyvesync.utils.helpers import Helpers
@@ -47,6 +49,7 @@ class VeSync: # pylint: disable=function-redefined
4749

4850
__slots__ = (
4951
'__weakref__',
52+
'_api_attempts',
5053
'_auth',
5154
'_close_session',
5255
'_debug',
@@ -130,6 +133,7 @@ class is instantiated, call `await manager.login()` to log in to VeSync servers,
130133
Object to store device state information
131134
"""
132135
self.session = session
136+
self._api_attempts = 0
133137
self._close_session = False
134138
self._debug = debug
135139
self.redact = redact
@@ -381,6 +385,7 @@ async def login(self) -> bool: # pylint: disable=W9006 # pylint mult docstring
381385
VeSyncAPIResponseError: If API response is invalid.
382386
VeSyncServerError: If server returns an error.
383387
"""
388+
self.enabled = False
384389
success = await self._auth.login()
385390
if success:
386391
self.enabled = True
@@ -427,6 +432,24 @@ async def __aexit__(self, *exec_info: object) -> None:
427432
return
428433
logger.debug('Session not closed, exiting context manager')
429434

435+
async def _reauthenticate(self) -> bool:
436+
"""Re-authenticate using stored username and password.
437+
438+
Returns:
439+
True if re-authentication successful, False otherwise
440+
"""
441+
self.enabled = False
442+
self._api_attempts += 1
443+
if self._api_attempts >= MAX_API_REAUTH_RETRIES:
444+
logger.error('Max API re-authentication attempts reached')
445+
raise VeSyncTokenError
446+
success = await self.auth.reauthenticate()
447+
if success:
448+
self.enabled = True
449+
self._api_attempts = 0
450+
return True
451+
return await self.auth.reauthenticate()
452+
430453
async def async_call_api(
431454
self,
432455
api: str,
@@ -463,6 +486,7 @@ async def async_call_api(
463486
if self.session is None:
464487
self.session = ClientSession()
465488
self._close_session = True
489+
self._api_attempts += 1
466490
response = None
467491
status_code = None
468492
if isinstance(json_object, DataClassORJSONMixin):
@@ -494,6 +518,13 @@ async def async_call_api(
494518
resp_dict = Helpers.try_json_loads(resp_bytes)
495519
if isinstance(resp_dict, dict):
496520
error_info = ErrorCodes.get_error_info(resp_dict.get('code'))
521+
if error_info.error_type == ErrorTypes.TOKEN_ERROR:
522+
if await self._reauthenticate():
523+
self.enabled = True
524+
return await self.async_call_api(
525+
api, method, json_object, headers
526+
)
527+
raise VeSyncTokenError
497528
if resp_dict.get('msg') is not None:
498529
error_info.message = f'{error_info.message} ({resp_dict["msg"]})'
499530
raise_api_errors(error_info)

src/tests/call_json.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,15 @@
9696
}
9797

9898

99+
def response_body(code: int = 0, msg: str | None = 'Success') -> dict[str, Any]:
100+
"""Return base response dictionary."""
101+
return {
102+
'code': code,
103+
'traceId': TestDefaults.trace_id,
104+
'msg': msg
105+
}
106+
107+
99108
class LoginResponses:
100109
GET_TOKEN_RESPONSE_SUCCESS = {
101110
"traceId": TestDefaults.trace_id,

src/tests/call_json_purifiers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ class PurifierDefaults:
8787
"level": PurifierDefaults.fan_level,
8888
"display": bool(PurifierDefaults.display),
8989
"child_lock": bool(PurifierDefaults.child_lock),
90-
"night_light": bool(PurifierDefaults.night_light),
90+
"night_light": PurifierDefaults.night_light.value,
9191
"configuration": {
9292
"display": bool(PurifierDefaults.display),
9393
"display_forever": PurifierDefaults.display_forever

src/tests/test_x_vesync_api_responses.py

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,36 +8,32 @@
88

99
from pyvesync import VeSync
1010
from pyvesync.utils.errors import VeSyncRateLimitError, VeSyncServerError
11+
1112
from pyvesync.const import API_BASE_URL_US
1213
from pyvesync.utils.errors import (
1314
VeSyncAPIStatusCodeError
1415
)
16+
import call_json
1517
from defaults import TestDefaults
1618
from aiohttp_mocker import AiohttpMockSession
1719

20+
1821
DEFAULT_ENDPOINT = '/endpoint'
1922
DEFAULT_POST_DATA = {'key': 'value'}
20-
21-
22-
def response_dict(code, msg):
23-
"""Return a response dictionary."""
24-
return {'code': code, 'msg': msg}
25-
26-
2723
PARAM_ARGS = "endpoint, method, resp_bytes, resp_status"
2824

2925
# Successful API calls should return the response in bytes and a 200 status code
30-
SUCCESS_RESP = response_dict(0, 'Success')
26+
SUCCESS_RESP = call_json.response_body(0, 'Success')
3127

3228

3329
# Rate limit errors should raise an exception in `async_call_api`
3430
RATE_LIMIT_CODE = -11003000
35-
RATE_LIMIT_RESP = response_dict(RATE_LIMIT_CODE, "Rate limit exceeded")
31+
RATE_LIMIT_RESP = call_json.response_body(RATE_LIMIT_CODE, "Rate limit exceeded")
3632

3733

3834
# Server errors should raise an exception in `async_call_api`
3935
SERVER_ERROR = -11102000
40-
SERVER_ERROR_RESP = response_dict(SERVER_ERROR, "Server error")
36+
SERVER_ERROR_RESP = call_json.response_body(SERVER_ERROR, "Server error")
4137

4238

4339
# Status code errors should raise an exception in `async_call_api`
@@ -53,7 +49,7 @@ def response_dict(code, msg):
5349
# Device errors should return the response and a 200 status code
5450
# with no exception thrown by `async_call_api`
5551
DEVICE_ERROR_CODE = -11901000
56-
DEVICE_ERROR_RESP = response_dict(DEVICE_ERROR_CODE, "Device error")
52+
DEVICE_ERROR_RESP = call_json.response_body(DEVICE_ERROR_CODE, "Device error")
5753

5854

5955
class TestApiFunc:
@@ -75,10 +71,6 @@ def setup(self, caplog):
7571
self.caplog = caplog
7672
self.caplog.set_level(logging.DEBUG)
7773
self.loop = asyncio.new_event_loop()
78-
# self.mock_api = self.mock_api_call.start()
79-
# self.mock_api.return_value.ok = True
80-
# self.mock = aioresponses()
81-
# self.mock.start()
8274
self.mock = MagicMock()
8375
self.manager = VeSync('EMAIL', 'PASSWORD')
8476
self.manager.verbose = True
@@ -116,7 +108,7 @@ def test_api_success(self, mock):
116108
@patch("pyvesync.vesync.ClientSession")
117109
def test_api_rate_limit(self, mock):
118110
"""Test rate limit error - raises `VeSyncRateLimitError` from `VeSync.async_call_api`."""
119-
rate_limit_resp = response_dict(RATE_LIMIT_CODE, "Rate limit exceeded")
111+
rate_limit_resp = call_json.response_body(RATE_LIMIT_CODE, "Rate limit exceeded")
120112
mock.return_value.request.return_value = AiohttpMockSession(
121113
method="post",
122114
url=API_BASE_URL_US + DEFAULT_ENDPOINT,

0 commit comments

Comments
 (0)