From 6bcd1f0685d936e35fda8d5942c80ee9a1622fe4 Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Sat, 29 Jun 2024 16:23:39 -0400 Subject: [PATCH 1/4] Clean up helpers & remove Py3.8 testing --- azure-pipelines.yml | 8 ++-- setup.py | 2 +- src/pyvesync/helpers.py | 98 +++++++++++++++-------------------------- 3 files changed, 41 insertions(+), 67 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 0ddac4c..ee78264 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -17,8 +17,8 @@ jobs: steps: - task: UsePythonVersion@0 inputs: - versionSpec: '3.8' - displayName: 'Use Python 3.8' + versionSpec: '3.9' + displayName: 'Use Python 3.9' - script: | python -m pip install --upgrade pip pip install -r requirements.txt @@ -42,12 +42,12 @@ jobs: vmImage: 'ubuntu-20.04' strategy: matrix: - Python38: - python.version: '3.8' Python39: python.version: '3.9' Python310: python.version: '3.10' + Python311: + python.version: '3.11' steps: diff --git a/setup.py b/setup.py index 88f512a..93053b9 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup( name='pyvesync', - version='2.1.10', + version='2.1.11', description='pyvesync is a library to manage Etekcity\ Devices, Cosori Air Fryers and Levoit Air \ Purifiers run on the VeSync app.', diff --git a/src/pyvesync/helpers.py b/src/pyvesync/helpers.py index d6a3b9e..a3a224c 100644 --- a/src/pyvesync/helpers.py +++ b/src/pyvesync/helpers.py @@ -6,7 +6,7 @@ import json import colorsys from dataclasses import dataclass, field, InitVar -from typing import NamedTuple, Optional, Union +from typing import Any, Dict, NamedTuple, Optional, Union import re import requests @@ -38,7 +38,7 @@ class Helpers: """VeSync Helper Functions.""" @staticmethod - def req_headers(manager) -> dict: + def req_headers(manager) -> Dict[str, str]: """Build header for api requests.""" headers = { 'accept-language': 'en', @@ -51,7 +51,7 @@ def req_headers(manager) -> dict: return headers @staticmethod - def req_header_bypass() -> dict: + def req_header_bypass() -> Dict[str, str]: """Build header for api requests on 'bypass' endpoint.""" return { 'Content-Type': 'application/json; charset=UTF-8', @@ -59,17 +59,17 @@ def req_header_bypass() -> dict: } @staticmethod - def req_body_base(manager) -> dict: + def req_body_base(manager) -> Dict[str, str]: """Return universal keys for body of api requests.""" return {'timeZone': manager.time_zone, 'acceptLanguage': 'en'} @staticmethod - def req_body_auth(manager) -> dict: + def req_body_auth(manager) -> Dict[str, str]: """Keys for authenticating api requests.""" return {'accountID': manager.account_id, 'token': manager.token} @staticmethod - def req_body_details() -> dict: + def req_body_details() -> Dict[str, str]: """Detail keys for api requests.""" return { 'appVersion': APP_VERSION, @@ -79,83 +79,57 @@ def req_body_details() -> dict: } @classmethod - def req_body(cls, manager, type_) -> dict: + def req_body(cls, manager, type_) -> Dict[str, Any]: """Builder for body of api requests.""" - body = {} + body = cls.req_body_base(manager) if type_ == 'login': - body = {**cls.req_body_base(manager), - **cls.req_body_details()} - body['email'] = manager.username - body['password'] = cls.hash_password(manager.password) - body['devToken'] = '' - body['userType'] = USER_TYPE - body['method'] = 'login' - elif type_ == 'devicedetail': - body = { - **cls.req_body_base(manager), - **cls.req_body_auth(manager), - **cls.req_body_details(), - } - body['method'] = 'devicedetail' - body['mobileId'] = MOBILE_ID - elif type_ == 'devicelist': - body = { - **cls.req_body_base(manager), - **cls.req_body_auth(manager), - **cls.req_body_details(), - } + body |= cls.req_body_details() # type: ignore + body |= { + 'email': manager.username, + 'password': cls.hash_password(manager.password), + 'devToken': '', + 'userType': USER_TYPE, + 'method': 'login' + } # type: ignore + return body + + body |= cls.req_body_auth(manager) # type: ignore + + if type_ == 'devicestatus': + return body + + body |= cls.req_body_details() # type: ignore + + if type_ == 'devicelist': body['method'] = 'devices' body['pageNo'] = '1' body['pageSize'] = '100' - elif type_ == 'devicestatus': - body = {**cls.req_body_base(manager), - **cls.req_body_auth(manager)} + + elif type_ == 'devicedetail': + body['method'] = 'devicedetail' + body['mobileId'] = MOBILE_ID + elif type_ == 'energy_week': - body = { - **cls.req_body_base(manager), - **cls.req_body_auth(manager), - **cls.req_body_details(), - } body['method'] = 'energyweek' body['mobileId'] = MOBILE_ID + elif type_ == 'energy_month': - body = { - **cls.req_body_base(manager), - **cls.req_body_auth(manager), - **cls.req_body_details(), - } body['method'] = 'energymonth' body['mobileId'] = MOBILE_ID + elif type_ == 'energy_year': - body = { - **cls.req_body_base(manager), - **cls.req_body_auth(manager), - **cls.req_body_details(), - } body['method'] = 'energyyear' body['mobileId'] = MOBILE_ID + elif type_ == 'bypass': - body = { - **cls.req_body_base(manager), - **cls.req_body_auth(manager), - **cls.req_body_details(), - } body['method'] = 'bypass' + elif type_ == 'bypassV2': - body = { - **cls.req_body_base(manager), - **cls.req_body_auth(manager), - **cls.req_body_details(), - } body['deviceRegion'] = DEFAULT_REGION body['method'] = 'bypassV2' + elif type_ == 'bypass_config': - body = { - **cls.req_body_base(manager), - **cls.req_body_auth(manager), - **cls.req_body_details(), - } body['method'] = 'firmwareUpdateInfo' return body From 3984cab201629c5abf2e23377f085d25cc5e61e7 Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Sat, 29 Jun 2024 16:43:43 -0400 Subject: [PATCH 2/4] Add Everest Air --- src/pyvesync/vesyncfan.py | 147 ++++++----- src/tests/api/vesyncfan/LAP-EL551S-AUS.yaml | 167 +++++++++++++ src/tests/call_json_fans.py | 258 +++++++++++--------- 3 files changed, 401 insertions(+), 171 deletions(-) create mode 100644 src/tests/api/vesyncfan/LAP-EL551S-AUS.yaml diff --git a/src/pyvesync/vesyncfan.py b/src/pyvesync/vesyncfan.py index 788bcd5..1fadf61 100644 --- a/src/pyvesync/vesyncfan.py +++ b/src/pyvesync/vesyncfan.py @@ -46,7 +46,7 @@ }, 'OASISMIST': { 'module': 'VeSyncHumid200300S', - 'models': ['LUH-O451S-WUS', 'LUH-O451S-WEU'], + 'models': ['LUH-O451S-WUS', 'LUH-O451S-WUSR', 'LUH-O451S-WEU'], 'features': ['warm_mist'], 'mist_modes': ['humidity', 'sleep', 'manual'], 'mist_levels': list(range(1, 10)), @@ -110,7 +110,7 @@ 'features': ['air_quality'] }, 'Vital100S': { - 'module': 'VeSyncVital', + 'module': 'VeSyncAirBaseV2', 'models': ['LAP-V102S-AASR', 'LAP-V102S-WUS', 'LAP-V102S-WEU', 'LAP-V102S-AUSR', 'LAP-V102S-WJP'], 'modes': ['manual', 'auto', 'sleep', 'off', 'pet'], @@ -118,12 +118,20 @@ 'levels': list(range(1, 5)) }, 'Vital200S': { - 'module': 'VeSyncVital', + 'module': 'VeSyncAirBaseV2', 'models': ['LAP-V201S-AASR', 'LAP-V201S-WJP', 'LAP-V201S-WEU', 'LAP-V201S-WUS', 'LAP-V201-AUSR', 'LAP-V201S-AUSR'], 'modes': ['manual', 'auto', 'sleep', 'off', 'pet'], 'features': ['air_quality'], 'levels': list(range(1, 5)) + }, + 'EverestAir': { + 'module': 'VeSyncAirBaseV2', + 'models': ['LAP-EL551S-AUS', 'LAP-EL551S-AEUR', + 'LAP-EL551S-WEU', 'LAP-EL551S-WUS'], + 'modes': ['manual', 'auto', 'sleep', 'off', 'turbo'], + 'features': ['air_quality', 'fan_rotate'], + 'levels': list(range(1, 4)) } } @@ -163,15 +171,15 @@ def __init__(self, details: Dict[str, list], manager): """Initialize air devices.""" super().__init__(details, manager) self.enabled = True - self.config_dict = model_features(self.device_type) - self.features = self.config_dict.get('features', []) - if not isinstance(self.config_dict.get('modes'), list): + self._config_dict = model_features(self.device_type) + self._features = self._config_dict.get('features', []) + if not isinstance(self._config_dict.get('modes'), list): logger.error( 'Please set modes for %s in the configuration', self.device_type) raise KeyError(f'Modes not set in configuration for {self.device_name}') - self.modes = self.config_dict['modes'] - if 'air_quality' in self.features: + self.modes = self._config_dict['modes'] + if 'air_quality' in self._features: self.air_quality_feature = True else: self.air_quality_feature = False @@ -405,7 +413,7 @@ def clear_timer(self) -> bool: def change_fan_speed(self, speed=None) -> bool: """Change fan speed based on levels in configuration dict.""" - speeds: list = self.config_dict.get('levels', []) + speeds: list = self._config_dict.get('levels', []) current_speed = self.speed if speed is not None: @@ -488,7 +496,7 @@ def set_child_lock(self, mode: bool) -> bool: def reset_filter(self) -> bool: """Reset filter to 100%.""" - if 'reset_filter' not in self.features: + if 'reset_filter' not in self._features: logger.debug("Filter reset not implemented for %s", self.device_type) return False @@ -774,15 +782,30 @@ def displayJSON(self) -> str: return json.dumps(sup_val, indent=4) -class VeSyncVital(VeSyncAirBypass): - """Levoit Vital 100S/200S Air Purifier Class.""" +class VeSyncAirBaseV2(VeSyncAirBypass): + """Levoit V2 Air Purifier Class.""" def __init__(self, details: Dict[str, list], manager): - """Initialize the VeSync Vital 100S/200S Air Purifier Class.""" + """Initialize the VeSync Base API V2 Air Purifier Class.""" super().__init__(details, manager) self.set_speed_level: Optional[int] = None self.auto_prefences: List[str] = ['default', 'efficient', 'quiet'] + def build_api_dict(self, method: str) -> Tuple[Dict, Dict]: + """Return default body for Bypass V2 API.""" + header = Helpers.bypass_header() + body = Helpers.bypass_body_v2(self.manager) + body['cid'] = self.cid + body['deviceId'] = self.cid + body['configModule'] = self.config_module + body['configModel'] = self.config_module + body['payload'] = { + 'method': method, + 'source': 'APP', + 'data': {} + } + return header, body + @property def light_detection(self) -> bool: """Return true if light detection feature is enabled.""" @@ -799,7 +822,7 @@ def light_detection_state(self) -> bool: return self.details['environment_light_state'] def get_details(self) -> None: - """Build Levoit 100S Purifier details dictionary.""" + """Build API V2 Purifier details dictionary.""" head, body = self.build_api_dict('getPurifierStatus') r, _ = Helpers.call_api( @@ -823,21 +846,6 @@ def get_details(self) -> None: if inner_result.get('configuration', {}): self.build_config_dict(inner_result.get('configuration', {})) - def build_api_dict(self, method: str) -> Tuple[Dict, Dict]: - """Return default body for Levoit Vital 100S/200S API.""" - header = Helpers.bypass_header() - body = Helpers.bypass_body_v2(self.manager) - body['cid'] = self.cid - body['deviceId'] = self.cid - body['configModule'] = self.config_module - body['configModel'] = self.config_module - body['payload'] = { - 'method': method, - 'source': 'APP', - 'data': {} - } - return header, body - def build_purifier_dict(self, dev_dict: dict) -> None: """Build Bypass purifier status dictionary.""" self.connection_status = 'online' @@ -862,8 +870,18 @@ def build_purifier_dict(self, dev_dict: dict) -> None: if self.air_quality_feature is True: self.details['air_quality_value'] = dev_dict.get( 'PM25', 0) - self.details['air_quality'] = dev_dict.get('air_quality', 0) - if dev_dict.get('timerRemain') is not None: + self.details['air_quality'] = dev_dict.get('AQLevel', 0) + if 'PM1' in dev_dict: + self.details['pm1'] = dev_dict['PM1'] + if 'PM10' in dev_dict: + self.details['pm10'] = dev_dict['PM10'] + if 'AQPercent' in dev_dict: + self.details['aq_percent'] = dev_dict['AQPercent'] + if 'fanRotateAngle' in dev_dict: + self.details['fan_rotate_angle'] = dev_dict['fanRotateAngle'] + if 'filterOpenState' in dev_dict: + self.details['filter_open_state'] = bool(dev_dict['filterOpenState']) + if dev_dict.get('timerRemain', 0) > 0: self.timer = Timer(dev_dict['timerRemain'], 'off') if isinstance(dev_dict.get('autoPreference'), dict): self.details['auto_preference_type'] = dev_dict.get( @@ -871,9 +889,24 @@ def build_purifier_dict(self, dev_dict: dict) -> None: else: self.details['auto_preference_type'] = None + def turbo_mode(self) -> bool: + """Turn on Turbo mode for compatible devices.""" + if 'turbo' in self.modes: + return self.mode_toggle('turbo') + logger.debug("Turbo mode not available for %s", self.device_name) + return False + def pet_mode(self) -> bool: - """Set Pet Mode for Levoit Vital 200S.""" - return self.mode_toggle('pet') + """Set Pet Mode for compatible devices.""" + if 'pet' in self.modes: + return self.mode_toggle('pet') + logger.debug("Pet mode not available for %s", self.device_name) + return False + + def set_night_light(self, mode: str) -> bool: + """TODO: Set night light.""" + logger.debug("Night light feature not configured") + return False def set_light_detection(self, toggle: bool) -> bool: """Enable/Disable Light Detection Feature.""" @@ -913,10 +946,7 @@ def toggle_switch(self, toggle: bool) -> bool: return False head, body = self.build_api_dict('setSwitch') - if toggle is True: - power = 1 - else: - power = 0 + power = int(toggle) body['payload']['data'] = { 'powerSwitch': power, 'switchIdx': 0 @@ -930,7 +960,7 @@ def toggle_switch(self, toggle: bool) -> bool: ) if r is not None and Helpers.nested_code_check(r): - if toggle: + if toggle is True: self.device_status = 'on' else: self.device_status = 'off' @@ -941,10 +971,7 @@ def toggle_switch(self, toggle: bool) -> bool: def set_child_lock(self, mode: bool) -> bool: """Levoit 100S/200S set Child Lock.""" - if mode: - toggle_id = 1 - else: - toggle_id = 0 + toggle_id = int(mode) head, body = self.build_api_dict('setChildLock') body['payload']['data'] = { 'childLockSwitch': toggle_id @@ -966,10 +993,7 @@ def set_child_lock(self, mode: bool) -> bool: def set_display(self, mode: bool) -> bool: """Levoit Vital 100S/200S Set Display on/off with True/False.""" - if mode: - mode_id = 1 - else: - mode_id = 0 + mode_id = int(mode) head, body = self.build_api_dict('setDisplay') body['payload']['data'] = { 'screenSwitch': mode_id @@ -1105,7 +1129,7 @@ def change_fan_speed(self, speed=None) -> bool: Speed to set based on levels in configuration dict, by default None If None, will cycle through levels in configuration dict """ - speeds: list = self.config_dict.get('levels', []) + speeds: list = self._config_dict.get('levels', []) current_speed = self.set_speed_level or 0 if speed is not None: @@ -1216,6 +1240,15 @@ def displayJSON(self) -> str: sup_val.update( {'Air Quality Value': str(self.details.get('air_quality_value', ''))} ) + everest_keys = { + 'pm1': 'PM1', + 'pm10': 'PM10', + 'fan_rotate_angle': 'Fan Rotate Angle', + 'filter_open_state': 'Filter Open State' + } + for key, value in everest_keys.items(): + if key in self.details: + sup_val.update({value: str(self.details[key])}) return json.dumps(sup_val, indent=4) @@ -1491,18 +1524,18 @@ def __init__(self, details, manager): """Initialize 200S/300S Humidifier class.""" super().__init__(details, manager) self.enabled = True - self.config_dict = model_features(self.device_type) - self.mist_levels = self.config_dict.get('mist_levels') - self.mist_modes = self.config_dict.get('mist_modes') - self.features = self.config_dict.get('features') - if 'warm_mist' in self.features: - self.warm_mist_levels = self.config_dict.get( + self._config_dict = model_features(self.device_type) + self.mist_levels = self._config_dict.get('mist_levels') + self.mist_modes = self._config_dict.get('mist_modes') + self._features = self._config_dict.get('features') + if 'warm_mist' in self._features: + self.warm_mist_levels = self._config_dict.get( 'warm_mist_levels', []) self.warm_mist_feature = True else: self.warm_mist_feature = False self.warm_mist_levels = [] - if 'nightlight' in self.config_dict.get('features', []): + if 'nightlight' in self._config_dict.get('features', []): self.night_light = True else: self.night_light = False @@ -2049,9 +2082,9 @@ class VeSyncSuperior6000S(VeSyncBaseDevice): def __init__(self, details, manager): """Initialize Superior 6000S Humidifier class.""" super().__init__(details, manager) - self.config_dict = model_features(self.device_type) - self.mist_levels = self.config_dict.get('mist_levels') - self.mist_modes = self.config_dict.get('mist_modes') + self._config_dict = model_features(self.device_type) + self.mist_levels = self._config_dict.get('mist_levels') + self.mist_modes = self._config_dict.get('mist_modes') self.connection_status = details.get('deviceProp', {}).get( 'connectionStatus', None) self.details = {} diff --git a/src/tests/api/vesyncfan/LAP-EL551S-AUS.yaml b/src/tests/api/vesyncfan/LAP-EL551S-AUS.yaml new file mode 100644 index 0000000..de84d91 --- /dev/null +++ b/src/tests/api/vesyncfan/LAP-EL551S-AUS.yaml @@ -0,0 +1,167 @@ +change_fan_speed: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 2.8.6 + cid: LAP-EL551S-AUS-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-EL551S-AUS-CID + deviceRegion: US + method: bypassV2 + payload: + data: + levelIdx: 0 + levelType: wind + manualSpeedLevel: 3 + method: setLevel + source: APP + phoneBrand: SM N9005 + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + method: post + url: /cloud/v2/deviceManaged/bypassV2 +manual_mode: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 2.8.6 + cid: LAP-EL551S-AUS-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-EL551S-AUS-CID + deviceRegion: US + method: bypassV2 + payload: + data: + levelIdx: 0 + levelType: wind + manualSpeedLevel: 1 + method: setLevel + source: APP + phoneBrand: SM N9005 + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + method: post + url: /cloud/v2/deviceManaged/bypassV2 +sleep_mode: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 2.8.6 + cid: LAP-EL551S-AUS-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-EL551S-AUS-CID + deviceRegion: US + method: bypassV2 + payload: + data: + workMode: sleep + method: setPurifierMode + source: APP + phoneBrand: SM N9005 + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + method: post + url: /cloud/v2/deviceManaged/bypassV2 +turn_off: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 2.8.6 + cid: LAP-EL551S-AUS-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-EL551S-AUS-CID + deviceRegion: US + method: bypassV2 + payload: + data: + powerSwitch: 0 + switchIdx: 0 + method: setSwitch + source: APP + phoneBrand: SM N9005 + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + method: post + url: /cloud/v2/deviceManaged/bypassV2 +turn_on: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 2.8.6 + cid: LAP-EL551S-AUS-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-EL551S-AUS-CID + deviceRegion: US + method: bypassV2 + payload: + data: + powerSwitch: 1 + switchIdx: 0 + method: setSwitch + source: APP + phoneBrand: SM N9005 + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + method: post + url: /cloud/v2/deviceManaged/bypassV2 +update: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 2.8.6 + cid: LAP-EL551S-AUS-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-EL551S-AUS-CID + deviceRegion: US + method: bypassV2 + payload: + data: {} + method: getPurifierStatus + source: APP + phoneBrand: SM N9005 + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + method: post + url: /cloud/v2/deviceManaged/bypassV2 \ No newline at end of file diff --git a/src/tests/call_json_fans.py b/src/tests/call_json_fans.py index ce2cf2f..f574e05 100644 --- a/src/tests/call_json_fans.py +++ b/src/tests/call_json_fans.py @@ -32,17 +32,18 @@ def status_response(request_body=None): METHOD_RESPONSES['DEVTYPE'].default_factory = lambda: ({"code": 0, "msg": "success"}, 200) """ + from copy import deepcopy from pyvesync import vesyncfan, helpers from utils import Defaults, FunctionResponses HUMID_MODELS = [] for model_type, model_dict in vesyncfan.humid_features.items(): - HUMID_MODELS.append(model_dict['models'][0]) + HUMID_MODELS.append(model_dict["models"][0]) AIR_MODELS = [] for model_type, model_dict in vesyncfan.air_features.items(): - AIR_MODELS.append(model_dict['models'][0]) + AIR_MODELS.append(model_dict["models"][0]) FANS = HUMID_MODELS + AIR_MODELS FANS_NUM = len(FANS) @@ -57,11 +58,7 @@ def INNER_RESULT(inner: dict) -> dict: "msg": "request success", "module": None, "stacktrace": None, - "result": { - "traceId": Defaults.trace_id, - "code": 0, - "result": inner - } + "result": {"traceId": Defaults.trace_id, "code": 0, "result": inner}, } @@ -73,33 +70,35 @@ class FanDefaults: warm_mist_level = 2 air_quality = 3 air_quality_value = 4 + filter_open = 0 + aq_percent = 75 + pm1 = 10 + pm10 = 5 + rotate_angle = 45 class FanDetails: - details_air = ( - { - 'code': 0, - 'msg': None, - 'deviceStatus': 'on', - 'connectionStatus': 'online', - 'activeTime': Defaults.active_time, - 'deviceImg': None, - 'deviceName': 'LV-PUR131S-NAME', - 'filterLife': { - 'change': False, - 'useHour': None, - 'percent': FanDefaults.filter_life, - }, - 'airQuality': 'excellent', - 'screenStatus': 'on', - 'mode': 'manual', - 'level': FanDefaults.fan_level, - 'schedule': None, - 'timer': None, - 'scheduleCount': 0, + details_air = ({ + "code": 0, + "msg": None, + "deviceStatus": "on", + "connectionStatus": "online", + "activeTime": Defaults.active_time, + "deviceImg": None, + "deviceName": "LV-PUR131S-NAME", + "filterLife": { + "change": False, + "useHour": None, + "percent": FanDefaults.filter_life, }, - 200, - ) + "airQuality": "excellent", + "screenStatus": "on", + "mode": "manual", + "level": FanDefaults.fan_level, + "schedule": None, + "timer": None, + "scheduleCount": 0, + }, 200) details_lv600s = ({ "traceId": Defaults.trace_id, @@ -125,10 +124,10 @@ class FanDetails: "configuration": { "auto_target_humidity": 50, "display": False, - "automatic_stop": True - } - } - } + "automatic_stop": True, + }, + }, + }, }, 200) details_classic200s300s = ({ @@ -153,10 +152,10 @@ class FanDetails: "configuration": { "auto_target_humidity": 50, "display": False, - "automatic_stop": True - } - } - } + "automatic_stop": True, + }, + }, + }, }, 200) details_oasismist1000S = ({ @@ -181,12 +180,13 @@ class FanDetails: "screenState": 0, "scheduleCount": 0, "timerRemain": 0, - "errorCode": 0 - } - } + "errorCode": 0, + }, + }, }, 200) details_core = ({ + "traceId": Defaults.trace_id, "code": 0, "msg": "request success", @@ -205,18 +205,12 @@ class FanDetails: "configuration": { "display": True, "display_forever": True, - "auto_preference": { - "type": "default", - "room_size": 0 - } - }, - "extension": { - "schedule_count": 0, - "timer_remain": 0 + "auto_preference": {"type": "default", "room_size": 0}, }, - "device_error_code": 0 - } - } + "extension": {"schedule_count": 0, "timer_remain": 0}, + "device_error_code": 0, + }, + }, }, 200) details_vital100s = ({ @@ -241,10 +235,7 @@ class FanDetails: "screenSwitch": Defaults.bin_toggle, "lightDetectionSwitch": Defaults.bin_toggle, "environmentLightState": Defaults.bin_toggle, - "autoPreference": { - "autoPreferenceType": "default", - "roomSize": 0 - }, + "autoPreference": {"autoPreferenceType": "default", "roomSize": 0}, "scheduleCount": 0, "timerRemain": 0, "efficientModeTimeRemain": 0, @@ -260,11 +251,11 @@ class FanDetails: "duringSleepMinutes": 480, "afterWakeUpPowerSwitch": 1, "afterWakeUpWorkMode": "auto", - "afterWakeUpFanSpeedLevel": 1 + "afterWakeUpFanSpeedLevel": 1, }, - "errorCode": 0 - } - } + "errorCode": 0, + }, + }, }, 200) details_superior6000S = ({ @@ -277,62 +268,101 @@ class FanDetails: "traceId": Defaults.trace_id, "code": 0, "result": { - "powerSwitch": 1, - "humidity": 44, - "targetHumidity": 50, - "virtualLevel": 1, - "mistLevel": 1, - "workMode": "manual", - "waterLacksState": 0, - "waterTankLifted": 0, - "autoStopSwitch": 1, - "autoStopState": 0, - "screenSwitch": 1, - "screenState": 1, - "scheduleCount": 0, - "timerRemain": 0, - "errorCode": 0, - "dryingMode": { - "dryingLevel": 1, - "autoDryingSwitch": 1, - "dryingState": 2, - "dryingRemain": 7200 + "powerSwitch": 1, + "humidity": 44, + "targetHumidity": 50, + "virtualLevel": 1, + "mistLevel": 1, + "workMode": "manual", + "waterLacksState": 0, + "waterTankLifted": 0, + "autoStopSwitch": 1, + "autoStopState": 0, + "screenSwitch": 1, + "screenState": 1, + "scheduleCount": 0, + "timerRemain": 0, + "errorCode": 0, + "dryingMode": { + "dryingLevel": 1, + "autoDryingSwitch": 1, + "dryingState": 2, + "dryingRemain": 7200, + }, + "autoPreference": 1, + "childLockSwitch": 0, + "filterLifePercent": 93, + "temperature": 662, }, - "autoPreference": 1, - "childLockSwitch": 0, - "filterLifePercent": 93, - "temperature": 662 - } - } + }, + }, 200) + + details_everest = ({ + "traceId": "1691789977402", + "code": 0, + "msg": "request success", + "module": None, + "stacktrace": None, + "result": { + "traceId": Defaults.trace_id, + "code": 0, + "result": { + "fanRotateAngle": FanDefaults.rotate_angle, + "filterOpenState": FanDefaults.filter_open, + "powerSwitch": Defaults.bin_toggle, + "filterLifePercent": FanDefaults.filter_life, + "workMode": "auto", + "manualSpeedLevel": FanDefaults.fan_level, + "fanSpeedLevel": FanDefaults.fan_level, + "AQLevel": FanDefaults.air_quality, + "AQPercent": FanDefaults.aq_percent, + "PM25": FanDefaults.air_quality_value, + "PM1": FanDefaults.pm1, + "PM10": FanDefaults.pm10, + "screenState": Defaults.bin_toggle, + "childLockSwitch": Defaults.bin_toggle, + "screenSwitch": Defaults.bin_toggle, + "lightDetectionSwitch": Defaults.bin_toggle, + "environmentLightState": Defaults.bin_toggle, + "autoPreference": {"autoPreferenceType": "default", "roomSize": 0}, + "routine": {"routineType": "normal", "runSeconds": 0}, + "scheduleCount": 0, + "timerRemain": 0, + "efficientModeTimeRemain": 0, + "ecoModeRunTime": 0, + "errorCode": 0, + }, + }, }, 200) DETAILS_RESPONSES = { - 'LV-PUR131S': FanDetails.details_air, - 'Classic300S': FanDetails.details_classic200s300s, - 'Classic200S': FanDetails.details_classic200s300s, - 'Dual200S': FanDetails.details_classic200s300s, - 'LUH-A602S-WUSR': FanDetails.details_lv600s, - 'Core200S': FanDetails.details_core, - 'Core300S': FanDetails.details_core, - 'Core400S': FanDetails.details_core, - 'Core600S': FanDetails.details_core, - 'LUH-O451S-WUS': FanDetails.details_lv600s, - 'LAP-V201S-AASR': FanDetails.details_vital100s, - 'LAP-V102S-AASR': FanDetails.details_vital100s, - 'LUH-M101S-WUS': FanDetails.details_oasismist1000S, - 'LEH-S601S-WUS': FanDetails.details_superior6000S + "LV-PUR131S": FanDetails.details_air, + "Classic300S": FanDetails.details_classic200s300s, + "Classic200S": FanDetails.details_classic200s300s, + "Dual200S": FanDetails.details_classic200s300s, + "LUH-A602S-WUSR": FanDetails.details_lv600s, + "Core200S": FanDetails.details_core, + "Core300S": FanDetails.details_core, + "Core400S": FanDetails.details_core, + "Core600S": FanDetails.details_core, + "LUH-O451S-WUS": FanDetails.details_lv600s, + "LAP-V201S-AASR": FanDetails.details_vital100s, + "LAP-V102S-AASR": FanDetails.details_vital100s, + "LUH-M101S-WUS": FanDetails.details_oasismist1000S, + "LEH-S601S-WUS": FanDetails.details_superior6000S, + "LAP-EL551S-AUS": FanDetails.details_everest, } -FunctionResponses.default_factory = lambda: ({ - "traceId": Defaults.trace_id, - "code": 0, - "msg": "request success", - "result": { +FunctionResponses.default_factory = lambda: ( + { "traceId": Defaults.trace_id, - "code": 0 - } -}, 200) + "code": 0, + "msg": "request success", + "result": {"traceId": Defaults.trace_id, "code": 0}, + }, + 200, +) METHOD_RESPONSES = {k: deepcopy(FunctionResponses) for k in FANS} @@ -341,9 +371,9 @@ class FanDetails: # Timer Responses for k in AIR_MODELS: - METHOD_RESPONSES[k]['set_timer'] = (INNER_RESULT({'id': 1}), 200) - METHOD_RESPONSES[k]['get_timer'] = (INNER_RESULT({'id': 1, - 'remain': 100, - 'total': 100, - 'action': 'off'}), 200) -FAN_TIMER = helpers.Timer(100, 'off') + METHOD_RESPONSES[k]["set_timer"] = (INNER_RESULT({"id": 1}), 200) + METHOD_RESPONSES[k]["get_timer"] = ( + INNER_RESULT({"id": 1, "remain": 100, "total": 100, "action": "off"}), + 200, + ) +FAN_TIMER = helpers.Timer(100, "off") From 5a0743236dcf7da61bdbe46bbfcd4aad4fec0b03 Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Sat, 29 Jun 2024 16:46:40 -0400 Subject: [PATCH 3/4] Update Readme --- README.md | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f509578..23d7865 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,9 @@ pyvesync is a library to manage VeSync compatible [smart home devices](#supporte - [Standard Air Purifier Properties \& Methods](#standard-air-purifier-properties--methods) - [Air Purifier Properties](#air-purifier-properties) - [Air Purifier Methods](#air-purifier-methods) - - [Levoit Purifier Core200S/300S/400S and Vital 100S/200S Properties](#levoit-purifier-core200s300s400s-and-vital-100s200s-properties) - - [Levoit Purifier Core200S/300S/400S and Vital 100S/200S Methods](#levoit-purifier-core200s300s400s-and-vital-100s200s-methods) - - [Levoit Vital 100S/200S Properties and Methods](#levoit-vital-100s200s-properties-and-methods) + - [Levoit Purifier Core200S/300S/400S and Vital 100S/200S Properties](#levoit-purifier-core200s300s400s-vital-100s200s--everest-air-properties) + - [Levoit Purifier Core200S/300S/400S, Vital 100S/200S & Everest Air Methods](#levoit-purifier-core200s300s400s-vital-100s200s--everest-air-methods) + - [Levoit Vital 100S/200S Properties and Methods](#levoit-vital-100s200s--everest-air-properties-and-methods) - [Lights API Methods \& Properties](#lights-api-methods--properties) - [Brightness Light Bulb Method and Properties](#brightness-light-bulb-method-and-properties) - [Light Bulb Color Temperature Methods and Properties](#light-bulb-color-temperature-methods-and-properties) @@ -94,6 +94,7 @@ pip install pyvesync 5. Core 600S 6. Vital 100S 7. Vital 200S +8. Everest Air ### Etekcity Bulbs @@ -330,13 +331,13 @@ Compatible levels for each model: - PUR131S [1, 2, 3] - Vital 100S/200S [1, 2, 3, 4] -#### Levoit Purifier Core200S/300S/400S and Vital 100S/200S Properties +#### Levoit Purifier Core200S/300S/400S, Vital 100S/200S & Everest Air Properties `VeSyncFan.child_lock` - Return the state of the child lock (True=On/False=off) `VeSyncAir.night_light` - Return the state of the night light (on/dim/off) **Not available on Vital 100S/200S** -#### Levoit Purifier Core200S/300S/400S and Vital 100S/200S Methods +#### Levoit Purifier Core200S/300S/400S, Vital 100S/200S & Everest Air Methods `VeSyncFan.child_lock_on()` Enable child lock @@ -348,7 +349,7 @@ Compatible levels for each model: `VeSyncFan.set_night_light('on'|'dim'|'off')` - Set night light brightness -`VeSyncFan.get_timer()` - Get any running timers, stores Timer DataClass in `VeSyncFan.timer` +`VeSyncFan.get_timer()` - Get any running timers, stores Timer DataClass in `VeSyncFan.timer`. See [Timer Dataclass](#timer-dataclass) `VeSyncFan.set_timer(timer_duration=3000)` - Set a timer for the device, only turns device off. Timer DataClass stored in `VeSyncFan.timer` @@ -356,7 +357,7 @@ Compatible levels for each model: `VeSyncFan.reset_filter()` - Reset filter to 100% **NOTE: Only available on Core200S** -#### Levoit Vital 100S/200S Properties and Methods +#### Levoit Vital 100S/200S & Everest Air Properties and Methods The Levoit Vital 100S/200S has additional features not available on other models. @@ -376,6 +377,23 @@ The Levoit Vital 100S/200S has additional features not available on other models `VeSyncFan.set_light_detection_off()` - Turn off light detection mode +#### Levoit Everest Air Properties & Methods + +`VeSyncFan.turbo_mode()` - Set turbo mode + +Additional properties in the `VeSyncFan['details']` dictionary: + +```python +VeSyncFan['Details'] = { + 'pm1': 0, # air quality reading of particulates 1.0 microns + 'pm10': 10, # air quality reading of particulates 10 microns + 'fan_rotate_angle': 45, # angle of fan vents + 'aq_percent': 45, # Air Quality percentage reading + 'filter_open_state': False # returns bool of filter open +} + +``` + ### Lights API Methods & Properties #### Brightness Light Bulb Method and Properties @@ -1029,7 +1047,7 @@ After: } ``` -# Contributing +## Contributing All [contributions](CONTRIBUTING.md) are welcome. From 74c07da7e1f60d19a83e40c77f04f40902aa4bfb Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Sat, 29 Jun 2024 17:10:03 -0400 Subject: [PATCH 4/4] Start adding docstrings --- src/pyvesync/vesync.py | 59 +++++++++++++++++-- src/pyvesync/vesyncfan.py | 118 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 168 insertions(+), 9 deletions(-) diff --git a/src/pyvesync/vesync.py b/src/pyvesync/vesync.py index 1ab537b..55c5a3a 100644 --- a/src/pyvesync/vesync.py +++ b/src/pyvesync/vesync.py @@ -74,11 +74,45 @@ def kitchen(dev_type, config, manager): class VeSync: # pylint: disable=function-redefined - """VeSync API functions.""" + """VeSync Manager Class.""" def __init__(self, username, password, time_zone=DEFAULT_TZ, debug=False, redact=True): - """Initialize VeSync class with username, password and time zone.""" + """Initialize VeSync Manager. + + This class is used as the manager for all VeSync objects, all methods and + API calls are performed from this class. Time zone, debug and redact are + optional. Time zone must be a string of an IANA time zone format. Once + class is instantiated, call `manager.login()` to log in to VeSync servers, + which returns `True` if successful. Once logged in, call `manager.update()` + to retrieve devices and update device details. + + Parameters: + ----------- + username : str + VeSync account username (usually email address) + password : str + VeSync account password + time_zone : str, optional + Time zone for device from IANA database, by default DEFAULT_TZ + debug : bool, optional + Enable debug logging, by default False + redact : bool, optional + Redact sensitive information in logs, by default True + + Attributes + ---------- + fans : list + List of VeSyncFan objects for humidifiers and air purifiers + outlets : list + List of VeSyncOutlet objects for smart plugs + switches : list + List of VeSyncSwitch objects for wall switches + bulbs : list + List of VeSyncBulb objects for smart bulbs + kitchen : list + List of VeSyncKitchen objects for smart kitchen appliances + """ self.debug = debug if debug: # pragma: no cover logger.setLevel(logging.DEBUG) @@ -308,7 +342,15 @@ def get_devices(self) -> bool: return proc_return def login(self) -> bool: - """Return True if log in request succeeds.""" + """Log into VeSync server. + + Username and password are provided when class is instantiated. + + Returns + ------- + bool + True if login successful, False if not + """ user_check = isinstance(self.username, str) and len(self.username) > 0 pass_check = isinstance(self.password, str) and len(self.password) > 0 if user_check is False: @@ -346,7 +388,16 @@ def device_time_check(self) -> bool: return False def update(self) -> None: - """Fetch updated information about devices.""" + """Fetch updated information about devices. + + Pulls devices list from VeSync and instantiates any new devices. Devices + are stored in the instance attributes `outlets`, `switches`, `fans`, and + `bulbs`. The `_device_list` attribute is a dictionary of these attributes. + + Returns + ------- + None + """ if self.device_time_check(): if not self.enabled: diff --git a/src/pyvesync/vesyncfan.py b/src/pyvesync/vesyncfan.py index 1fadf61..cdd7871 100644 --- a/src/pyvesync/vesyncfan.py +++ b/src/pyvesync/vesyncfan.py @@ -168,7 +168,52 @@ class VeSyncAirBypass(VeSyncBaseDevice): """Base class for Levoit Purifier Bypass API Calls.""" def __init__(self, details: Dict[str, list], manager): - """Initialize air devices.""" + """Initialize air purifier devices. + + Instantiated by VeSync manager object. Inherits from + VeSyncBaseDevice class. + + Arguments + ---------- + details : dict + Dictionary of device details + manager : VeSync + Instantiated VeSync object used to make API calls + + Attributes + ---------- + modes : list + List of available operation modes for device + air_quality_feature : bool + True if device has air quality sensor + details : dict + Dictionary of device details + timer : Timer + Timer object for device, None if no timer exists. See `Timer` class + config : dict + Dictionary of device configuration + + Notes + ----- + The `details` attribute holds device information that is updated when + the `update()` method is called. An example of the `details` attribute: + >>> { + >>> 'filter_life': 0, + >>> 'mode': 'manual', + >>> 'level': 0, + >>> 'display': False, + >>> 'child_lock': False, + >>> 'night_light': 'off', + >>> 'air_quality': 0 # air quality level + >>> 'air_quality_value': 0, # PM2.5 value from device, + >>> 'display_forever': False + >>> } + + See Also + -------- + VeSyncBaseDevice : Parent class for all VeSync devices + + """ super().__init__(details, manager) self.enabled = True self._config_dict = model_features(self.device_type) @@ -297,7 +342,31 @@ def update(self): self.get_details() def get_timer(self) -> Optional[Timer]: - """Retrieve running timer from purifier.""" + """Retrieve running timer from purifier. + + Returns Timer object if timer is running, None if no timer is running. + + Arguments + ---------- + None + + Returns + ------- + Timer or None + + Notes + ----- + Timer object tracks the time remaining based on the last update. Timer + properties include `status`, `time_remaining`, `duration`, `action`, + `paused` and `done`. The methods `start()`, `end()` and `pause()` + are available but should be called through the purifier object + to update through the API. + + See Also + -------- + Timer : Timer object used to track device timers + + """ head, body = self.build_api_dict('getTimer') body['payload']['data'] = {} if not head and not body: @@ -345,6 +414,11 @@ def set_timer(self, timer_duration: int) -> bool: ---------- timer_duration: int Duration of timer in seconds + + Returns + ------- + bool + """ if self.device_status != 'on': logger.debug("Can't set timer when device is off") @@ -382,7 +456,15 @@ def set_timer(self, timer_duration: int) -> bool: return True def clear_timer(self) -> bool: - """Clear timer.""" + """Clear timer. + + Returns True if no error is returned from API call. + + Returns + ------- + bool + + """ self.get_timer() if self.timer is None: logger.debug('No timer to clear') @@ -465,7 +547,20 @@ def child_lock_off(self) -> bool: return self.set_child_lock(False) def set_child_lock(self, mode: bool) -> bool: - """Set Bypass child lock.""" + """Set Bypass child lock. + + Set child lock to on or off. + + Arguments + ---------- + mode: bool + True to turn child lock on, False to turn off + + Returns + ------- + bool + + """ if mode not in (True, False): logger.debug('Invalid mode passed to set_child_lock - %s', mode) return False @@ -519,7 +614,20 @@ def reset_filter(self) -> bool: return False def mode_toggle(self, mode: str) -> bool: - """Set purifier mode - sleep or manual.""" + """Set purifier mode - sleep or manual. + + Set purifier mode based on devices available modes. + + Arguments + ---------- + mode: str + Mode to set purifier. Based on device modes in attribute `modes` + + Returns + ------- + bool + + """ if mode.lower() not in self.modes: logger.debug('Invalid purifier mode used - %s', mode)