From ed74b13e9afb538b9f4af01fdb9c10baca2c8cc7 Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Thu, 18 May 2023 23:13:06 -0400 Subject: [PATCH 1/6] Add Levoit Vital 100S --- src/pyvesync/vesyncfan.py | 225 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) diff --git a/src/pyvesync/vesyncfan.py b/src/pyvesync/vesyncfan.py index a5f19fd..0dcb239 100644 --- a/src/pyvesync/vesyncfan.py +++ b/src/pyvesync/vesyncfan.py @@ -95,6 +95,13 @@ 'models': ['LV-PUR131S', 'LV-RH131S'], 'features': ['air_quality'] }, + 'LAP-V102S-AAS': { + 'module': 'VeSyncVital100S', + 'models': ['LAP-V102S-AASR'], + 'modes': ['manual', 'auto', 'off'], + 'features': ['air_quality'], + 'levels': list(range(1, 5)) + } } @@ -718,6 +725,224 @@ def displayJSON(self) -> str: return json.dumps(sup_val, indent=4) +class VeSyncVital100S(VeSyncAirBypass): + """Levoit Vital 100S Air Purifier Class.""" + + def __init__(self, details: Dict[str, list], manager): + super().__init__(details, manager) + + def update(self): + """Update Purifier details.""" + self.get_details() + + def get_details(self) -> None: + """Build Bypass Purifier details dictionary.""" + head = Helpers.bypass_header() + body = Helpers.bypass_body_v2(self.manager) + body['cid'] = self.cid + body['deviceId'] = self.cid + body['configModule'] = self.config_module + body['payload'] = { + 'method': 'getPurifierStatus', + 'source': 'APP', + 'data': {} + } + + r, _ = Helpers.call_api( + '/cloud/v2/deviceManaged/bypassV2', + method='post', + headers=head, + json_object=body, + ) + if not isinstance(r, dict): + logger.debug('Error in purifier response') + return + if not isinstance(r.get('result'), dict): + logger.debug('Error in purifier response') + return + outer_result = r.get('result', {}) + inner_result = None + + if outer_result: + inner_result = r.get('result', {}).get('result') + if inner_result is not None and Helpers.code_check(r): + if outer_result.get('code') == 0: + self.build_purifier_dict(inner_result) + else: + logger.debug('error in inner result dict from purifier') + if inner_result.get('configuration', {}): + self.build_config_dict(inner_result.get('configuration', {})) + else: + logger.debug('No configuration found in purifier status') + else: + logger.debug('Error in purifier response') + + def build_purifier_dict(self, dev_dict: dict) -> None: + """Build Bypass purifier status dictionary.""" + power_switch = dev_dict.get('power_switch', 0) + if power_switch == 1: + self.enabled = True + self.device_status = 'on' + else: + self.enabled = False + self.device_status = 'off' + self.details['filter_life'] = dev_dict.get('filterLifePercent', 0) + self.mode = dev_dict.get('workMode', 'manual') + self.speed = dev_dict.get('manualSpeedLevel', 0) + self.details['display'] = dev_dict.get('display', False) + self.details['child_lock'] = True if dev_dict.get('childLockSwitch', + 0) == 0 else False + self.details['night_light'] = dev_dict.get('night_light', 'off') + self.details['display'] = True if dev_dict.get('screenState', 0) == 1 else False + self.details['display_forever'] = dev_dict.get('display_forever', + False) + self.details['light_detection_switch'] = True if dev_dict.get( + 'lightDetectionSwitch', 0) == 1 else False + 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.timer = Timer(dev_dict['timerRemain'], 'off') + + def toggle_switch(self, toggle: bool) -> bool: + """Toggle purifier on/off.""" + if not isinstance(toggle, bool): + logger.debug('Invalid toggle value for purifier switch') + return False + + head = Helpers.bypass_header() + body = Helpers.bypass_body_v2(self.manager) + if toggle is True: + power = 1 + else: + power = 0 + body['cid'] = self.cid + body['deviceId'] = self.cid + body['configModule'] = self.config_module + body['payload'] = { + 'data': { + 'powerSwitch': power, + 'switchIdx': 0 + }, + 'method': 'setSwitch', + 'source': 'APP' + } + + r, _ = Helpers.call_api( + '/cloud/v2/deviceManaged/bypassV2', + method='post', + headers=head, + json_object=body, + ) + + if r is not None and Helpers.code_check(r): + if toggle: + self.device_status = 'on' + else: + self.device_status = 'off' + return True + logger.debug("Error toggling purifier - %s", + self.device_name) + return False + + def set_child_lock(self, mode: bool) -> bool: + """Levoit 100S Set Child Lock not Implemented.""" + logger.debug("Child lock not implemented for %s", self.device_name) + return False + + def set_display(self, mode: bool) -> bool: + """Levoit Vital 100S Set Display not Implemented.""" + logger.debug("Display not implemented for %s", self.device_name) + return False + + def sleep_mode(self) -> bool: + """Levoit Vital 100S Sleep Mode Not Implemented.""" + logger.debug("Sleep mode not implemented for %s", self.device_name) + return False + + def change_fan_speed(self, speed=None) -> bool: + """Change fan speed based on levels in configuration dict.""" + speeds: list = self.config_dict.get('levels', []) + current_speed = self.speed + + if speed is not None: + if speed not in speeds: + logger.debug("%s is invalid speed - valid speeds are %s", + speed, str(speeds)) + return False + new_speed = speed + else: + if current_speed == speeds[-1]: + new_speed = speeds[0] + else: + current_index = speeds.index(current_speed) + new_speed = speeds[current_index + 1] + + body = Helpers.req_body(self.manager, 'devicestatus') + body['uuid'] = self.uuid + + head, body = self.build_api_dict('setLevel') + if not head and not body: + return False + body['deviceId'] = self.cid + body['payload']['data'] = { + 'levelIdx': 0, + 'manualSpeedLevel': new_speed, + 'levelType': 'wind' + } + + r, _ = Helpers.call_api( + '/cloud/v2/deviceManaged/bypassV2', + method='post', + headers=head, + json_object=body, + ) + + if r is not None and Helpers.code_check(r): + self.speed = new_speed + self.mode = 'manual' + return True + logger.debug('Error changing %s speed', self.device_name) + return False + + def mode_toggle(self, mode: str) -> bool: + """Set purifier mode - sleep or manual.""" + if mode.lower() not in self.modes: + logger.debug('Invalid purifier mode used - %s', + mode) + return False + + # Call change_fan_speed if mode is set to manual + if mode == 'manual': + if self.speed is None or self.speed == 0: + return self.change_fan_speed(1) + else: + return self.change_fan_speed(self.speed) + + head, body = self.build_api_dict('setPurifierMode') + if not head and not body: + return False + + body['deviceId'] = self.cid + body['payload']['data'] = { + 'workMode': mode.lower() + } + + r, _ = Helpers.call_api( + '/cloud/v2/deviceManaged/bypassV2', + method='post', + headers=head, + json_object=body, + ) + + if Helpers.code_check(r): + self.mode = mode + return True + logger.debug('Error setting purifier mode') + return False + + class VeSyncAir131(VeSyncBaseDevice): """Levoit Air Purifier Class.""" From 9b9fa15b0911c8db8e75da8102903d4c365a1f38 Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Mon, 29 May 2023 13:51:09 -0400 Subject: [PATCH 2/6] Update Model Numbers --- src/pyvesync/vesyncfan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pyvesync/vesyncfan.py b/src/pyvesync/vesyncfan.py index 0dcb239..7c3ef41 100644 --- a/src/pyvesync/vesyncfan.py +++ b/src/pyvesync/vesyncfan.py @@ -97,7 +97,7 @@ }, 'LAP-V102S-AAS': { 'module': 'VeSyncVital100S', - 'models': ['LAP-V102S-AASR'], + 'models': ['LAP-V102S-AASR', 'LAP-V102S-WUS', 'LAP-V102S-WEU', 'LAP-V102S-AUSR'], 'modes': ['manual', 'auto', 'off'], 'features': ['air_quality'], 'levels': list(range(1, 5)) From 8921e4abe538be6ae1c5a7d3382608d7463bda6e Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Fri, 16 Jun 2023 21:36:40 -0400 Subject: [PATCH 3/6] Additional vital 100S methods and properties --- README.md | 31 +++- src/pyvesync/vesyncfan.py | 313 +++++++++++++++++++++++++++++++------- 2 files changed, 287 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 9b5ca52..7363419 100644 --- a/README.md +++ b/README.md @@ -29,8 +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 Properties](#levoit-purifier-core200s300s400s-properties) - - [Levoit Purifier Core200S/300S/400S Methods](#levoit-purifier-core200s300s400s-methods) + - [Levoit Purifier Core200S/300S/400S and Vital 100S Properties](#levoit-purifier-core200s300s400s-and-vital-100s-properties) + - [Levoit Purifier Core200S/300S/400S and Vital 100S Methods](#levoit-purifier-core200s300s400s-and-vital-100s-methods) + - [Levoit 100S Properties and Methods](#levoit-100s-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) @@ -91,6 +92,7 @@ pip install pyvesync 3. Core 300S 4. Core 400S 5. Core 600S +6. Vital 100S ### Etekcity Bulbs @@ -324,14 +326,15 @@ Compatible levels for each model: - Core 200S [1, 2, 3] - Core 300S/400S [1, 2, 3, 4] - PUR131S [1, 2, 3] +- Vital 100S [1, 2, 3, 4] -#### Levoit Purifier Core200S/300S/400S Properties +#### Levoit Purifier Core200S/300S/400S and Vital 100S 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) +`VeSyncAir.night_light` - Return the state of the night light (on/dim/off) *Not available on Vital 100S -#### Levoit Purifier Core200S/300S/400S Methods +#### Levoit Purifier Core200S/300S/400S and Vital 100S Methods `VeSyncFan.child_lock_on()` Enable child lock @@ -349,6 +352,24 @@ Compatible levels for each model: `VeSyncFan.clear_timer()` - Cancel any running timer +#### Levoit 100S Properties and Methods + +The Levoit 100S has additional features not available on other models. + +`VeSyncFan.set_fan_speed` - The manual fan speed that is currently set (remains constant when in auto/sleep mode) + +`VeSyncFan.fan_level` - Returns the fan level purifier is currently operating on whether in auto/manual/sleep mode + +`VeSyncFan.light_detection` - Returns the state of the light detection mode (True=On/False=off) + +`VeSyncFan.light_detection_state` - Returns whether light is detected (True/False) + +`VeSyncFan.set_auto_preference(preference='default', room_size=600)` - Set the auto mode preference. Preference can be 'default', 'efficient' or quiet. Room size defaults to 600 + +`VeSyncFan.set_light_detection_on()` - Turn on light detection mode + +`VeSyncFan.set_light_detection_off()` - Turn off light detection mode + ### Lights API Methods & Properties #### Brightness Light Bulb Method and Properties diff --git a/src/pyvesync/vesyncfan.py b/src/pyvesync/vesyncfan.py index 7c3ef41..5e2e88c 100644 --- a/src/pyvesync/vesyncfan.py +++ b/src/pyvesync/vesyncfan.py @@ -2,7 +2,7 @@ import json import logging -from typing import Dict, Tuple, Union, Optional +from typing import Any, Dict, List, Tuple, Union, Optional from pyvesync.vesyncbasedevice import VeSyncBaseDevice from pyvesync.helpers import Helpers, Timer @@ -98,7 +98,7 @@ 'LAP-V102S-AAS': { 'module': 'VeSyncVital100S', 'models': ['LAP-V102S-AASR', 'LAP-V102S-WUS', 'LAP-V102S-WEU', 'LAP-V102S-AUSR'], - 'modes': ['manual', 'auto', 'off'], + 'modes': ['manual', 'auto', 'sleep', 'off'], 'features': ['air_quality'], 'levels': list(range(1, 5)) } @@ -152,7 +152,7 @@ def __init__(self, details: Dict[str, list], manager): self.air_quality_feature = True else: self.air_quality_feature = False - self.details: Dict[str, Union[str, int, float, bool]] = { + self.details: Dict[str, Any] = { 'filter_life': 0, 'mode': 'manual', 'level': 0, @@ -730,23 +730,31 @@ class VeSyncVital100S(VeSyncAirBypass): def __init__(self, details: Dict[str, list], manager): super().__init__(details, manager) + self.set_speed_level: Optional[int] = None + self.auto_prefences: List[str] = ['default', 'efficient', 'quiet'] def update(self): """Update Purifier details.""" self.get_details() - def get_details(self) -> None: - """Build Bypass Purifier details dictionary.""" - head = Helpers.bypass_header() + def build_api_dict(self, method: str) -> Tuple[Dict, Dict]: + """Return default body for Levoit Vital 100s 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': 'getPurifierStatus', + 'method': method, 'source': 'APP', 'data': {} } + return header, body + + def get_details(self) -> None: + """Build Levoit 100S Purifier details dictionary.""" + head, body = self.build_api_dict('getPurifierStatus') r, _ = Helpers.call_api( '/cloud/v2/deviceManaged/bypassV2', @@ -780,30 +788,84 @@ def get_details(self) -> None: def build_purifier_dict(self, dev_dict: dict) -> None: """Build Bypass purifier status dictionary.""" power_switch = dev_dict.get('power_switch', 0) - if power_switch == 1: - self.enabled = True - self.device_status = 'on' - else: - self.enabled = False - self.device_status = 'off' - self.details['filter_life'] = dev_dict.get('filterLifePercent', 0) + self.enabled = power_switch + self.device_status = 'on' if power_switch else 'off' self.mode = dev_dict.get('workMode', 'manual') - self.speed = dev_dict.get('manualSpeedLevel', 0) + + self.speed = dev_dict.get('fanSpeedLevel', 0) + + self.set_speed_level = dev_dict.get('manualSpeedLevel', 1) + + self.details['filter_life'] = dev_dict.get('filterLifePercent', 0) self.details['display'] = dev_dict.get('display', False) - self.details['child_lock'] = True if dev_dict.get('childLockSwitch', - 0) == 0 else False + self.details['child_lock'] = bool(dev_dict.get('childLockSwitch', 0)) self.details['night_light'] = dev_dict.get('night_light', 'off') - self.details['display'] = True if dev_dict.get('screenState', 0) == 1 else False - self.details['display_forever'] = dev_dict.get('display_forever', - False) - self.details['light_detection_switch'] = True if dev_dict.get( - 'lightDetectionSwitch', 0) == 1 else False + self.details['display'] = bool(dev_dict.get('screenState', 0)) + self.details['display_forever'] = dev_dict.get('display_forever', False) + self.details['light_detection_switch'] = bool(dev_dict.get('lightDetectionSwitch', 0)) + self.details['environment_light_state'] = bool(dev_dict.get('environmentLightState', 0)) + self.details['screen_state'] = bool(dev_dict.get('screenState', 0)) + self.details['screen_switch'] = bool(dev_dict.get('screenSwitch', 0)) + 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.timer = Timer(dev_dict['timerRemain'], 'off') + if isinstance(dev_dict.get('autoPreference'), dict): + self.details['auto_preference_type'] = dev_dict.get('autoPreference', {}).get('autoPreferenceType', 'default') + else: + self.details['auto_preference_type'] = None + + def set_light_detection(self, toggle: bool) -> bool: + """Enable/Disable Light Detection Feature.""" + if toggle is True: + toggle_id = 1 + else: + toggle_id = 0 + if self.details['light_detection_switch'] == toggle_id: + logger.debug("Light Detection is already set to %s", toggle_id) + return True + + head, body = self.build_api_dict('setLightDetectionSwitch') + body['payload']['data']['lightDetectionSwitch'] = toggle_id + r, _ = Helpers.call_api( + '/cloud/v2/deviceManaged/bypassV2', + method='post', + headers=head, + json_object=body, + ) + + if r is not None and Helpers.code_check(r): + self.details['light_detection'] = toggle + return True + logger.debug("Error toggling purifier - %s", + self.device_name) + return False + + def set_light_detection_on(self) -> bool: + """Turn on light detection feature.""" + return self.set_light_detection(True) + + def set_light_detection_off(self) -> bool: + """Turn off light detection feature.""" + return self.set_light_detection(False) + + @property + def light_detection(self) -> bool: + """Return true if light detection feature is enabled.""" + return self.details['light_detection_switch'] + + @light_detection.setter + def light_detection(self, toggle: bool) -> None: + """Set light detection feature.""" + self.details['light_detection_switch'] = toggle + + @property + def light_detection_state(self) -> bool: + """Return true if light is detected.""" + return self.details['environment_light_state'] def toggle_switch(self, toggle: bool) -> bool: """Toggle purifier on/off.""" @@ -811,23 +873,15 @@ def toggle_switch(self, toggle: bool) -> bool: logger.debug('Invalid toggle value for purifier switch') return False - head = Helpers.bypass_header() - body = Helpers.bypass_body_v2(self.manager) + head, body = self.build_api_dict('setSwitch') if toggle is True: power = 1 else: power = 0 - body['cid'] = self.cid - body['deviceId'] = self.cid - body['configModule'] = self.config_module - body['payload'] = { - 'data': { + body['payload']['data'] = { 'powerSwitch': power, 'switchIdx': 0 - }, - 'method': 'setSwitch', - 'source': 'APP' - } + } r, _ = Helpers.call_api( '/cloud/v2/deviceManaged/bypassV2', @@ -846,25 +900,171 @@ def toggle_switch(self, toggle: bool) -> bool: self.device_name) return False - def set_child_lock(self, mode: bool) -> bool: - """Levoit 100S Set Child Lock not Implemented.""" - logger.debug("Child lock not implemented for %s", self.device_name) + def set_child_lock(self, toggle: bool) -> bool: + """Levoit 100S set Child Lock.""" + if toggle: + toggle_id = 1 + else: + toggle_id = 0 + head, body = self.build_api_dict('setChildLock') + body['payload']['data'] = { + 'childLockSwitch': toggle_id + } + + r, _ = Helpers.call_api( + '/cloud/v2/deviceManaged/bypassV2', + method='post', + headers=head, + json_object=body, + ) + + if r is not None and Helpers.code_check(r): + self.details['child_lock'] = toggle + return True + + logger.debug("Error toggling purifier child lock - %s", self.device_name) return False - def set_display(self, mode: bool) -> bool: - """Levoit Vital 100S Set Display not Implemented.""" - logger.debug("Display not implemented for %s", self.device_name) + def set_display(self, toggle: bool) -> bool: + """Levoit Vital 100S Set Display on/off with True/False.""" + if toggle: + toggle_id = 1 + else: + toggle_id = 0 + head, body = self.build_api_dict('setDisplay') + body['payload']['data'] = { + 'screenSwitch': toggle_id + } + + r, _ = Helpers.call_api( + '/cloud/v2/deviceManaged/bypassV2', + method='post', + headers=head, + json_object=body, + ) + + if r is not None and Helpers.code_check(r): + self.details['screen_switch'] = toggle + return True + + logger.debug("Error toggling purifier display - %s", self.device_name) return False - def sleep_mode(self) -> bool: - """Levoit Vital 100S Sleep Mode Not Implemented.""" - logger.debug("Sleep mode not implemented for %s", self.device_name) + def set_timer(self, timer_duration: int, action: str = 'off', method: str = 'powerSwitch') -> bool: + """Set timer for Levoit 100S. + + Parameters + ---------- + timer_duration : int + Timer duration in seconds. + action : str, optional + Action to perform, on or off, by default 'off' + method : str, optional + Method to use, by default 'powerSwitch' - TODO: Implement other methods + Returns + ------- + bool + """ + if action not in ['on', 'off']: + logger.debug('Invalid action for timer') + return False + if method not in ['powerSwitch']: + logger.debug('Invalid method for timer') + return False + action_id = 1 if action == 'on' else 0 + + head, body = self.build_api_dict('addTimerV2') + body['payload']['subDeviceNo'] = 0 + body['payload']['data'] = { + "startAct": [{ + "type": method, + "num": 0, + "act": action_id, + }], + "total": timer_duration, + "subDeviceNo": 0 + } + + r, _ = Helpers.call_api( + '/cloud/v2/deviceManaged/bypassV2', + method='post', + headers=head, + json_object=body, + ) + + if r is not None and Helpers.code_check(r): + self.timer = Timer(timer_duration, action) + return True + + logger.debug("Error setting timer for - %s", self.device_name) + return False + + def clear_timer(self) -> bool: + """Clear running timer.""" + head, body = self.build_api_dict('delTimerV2') + body['payload']['subDeviceNo'] = 0 + body['payload']['data'] = {'id': 1, "subDeviceNo": 0} + + r, _ = Helpers.call_api( + '/cloud/v2/deviceManaged/bypassV2', + method='post', + headers=head, + json_object=body, + ) + + if r is not None and Helpers.code_check(r): + self.timer = None + return True + + logger.debug("Error setting timer for - %s", self.device_name) + return False + + def set_auto_preference(self, preference: str = 'default', room_size: int = 600) -> bool: + """Set Levoit Vital 100S auto mode. + + Parameters + ---------- + preference : str, optional + Preference for auto mode, by default 'default' + options are: default, efficient, quiet + room_size : int, optional + Room size in square feet, by default 600 + """ + if preference not in self.auto_prefences: + logger.debug("%s is invalid preference - valid preferences are default, efficient, quiet", + preference) + return False + head, body = self.build_api_dict('setAutoPreference') + body['payload']['data'] = { + 'autoPreference': preference, + 'roomSize': room_size, + } + + r, _ = Helpers.call_api( + '/cloud/v2/deviceManaged/bypassV2', + method='post', + headers=head, + json_object=body, + ) + + if r is not None and Helpers.code_check(r): + self.details['auto_preference'] = preference + return True + + logger.debug("Error setting auto preference for - %s", self.device_name) return False def change_fan_speed(self, speed=None) -> bool: - """Change fan speed based on levels in configuration dict.""" + """Change fan speed based on levels in configuration dict. + + Parameters + ---------- + speed : int, optional + 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', []) - current_speed = self.speed + current_speed = self.set_speed_level or 0 if speed is not None: if speed not in speeds: @@ -873,19 +1073,15 @@ def change_fan_speed(self, speed=None) -> bool: return False new_speed = speed else: - if current_speed == speeds[-1]: + if current_speed == speeds[-1] or current_speed == 0: new_speed = speeds[0] else: current_index = speeds.index(current_speed) new_speed = speeds[current_index + 1] - body = Helpers.req_body(self.manager, 'devicestatus') - body['uuid'] = self.uuid - head, body = self.build_api_dict('setLevel') - if not head and not body: + if not head or not body: return False - body['deviceId'] = self.cid body['payload']['data'] = { 'levelIdx': 0, 'manualSpeedLevel': new_speed, @@ -900,14 +1096,24 @@ def change_fan_speed(self, speed=None) -> bool: ) if r is not None and Helpers.code_check(r): - self.speed = new_speed + self.set_speed_level = new_speed self.mode = 'manual' return True logger.debug('Error changing %s speed', self.device_name) return False def mode_toggle(self, mode: str) -> bool: - """Set purifier mode - sleep or manual.""" + """Set Levoit 100S purifier mode. + + Parameters + ---------- + mode : str + Mode to set purifier to, options are: auto, manual, sleep + + Returns + ------- + bool + """ if mode.lower() not in self.modes: logger.debug('Invalid purifier mode used - %s', mode) @@ -920,6 +1126,9 @@ def mode_toggle(self, mode: str) -> bool: else: return self.change_fan_speed(self.speed) + if mode == 'off': + return self.turn_off() + head, body = self.build_api_dict('setPurifierMode') if not head and not body: return False From 4449693eee5df20295d0ec945e9af3c5864c7146 Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Fri, 16 Jun 2023 22:00:16 -0400 Subject: [PATCH 4/6] Add Levoit 200S Support --- README.md | 21 ++++++---- src/pyvesync/vesyncfan.py | 87 +++++++++++++++++++++------------------ 2 files changed, 60 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 7363419..797849a 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 Properties](#levoit-purifier-core200s300s400s-and-vital-100s-properties) - - [Levoit Purifier Core200S/300S/400S and Vital 100S Methods](#levoit-purifier-core200s300s400s-and-vital-100s-methods) - - [Levoit 100S Properties and Methods](#levoit-100s-properties-and-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) - [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) @@ -93,6 +93,7 @@ pip install pyvesync 4. Core 400S 5. Core 600S 6. Vital 100S +7. Vital 200S ### Etekcity Bulbs @@ -326,15 +327,15 @@ Compatible levels for each model: - Core 200S [1, 2, 3] - Core 300S/400S [1, 2, 3, 4] - PUR131S [1, 2, 3] -- Vital 100S [1, 2, 3, 4] +- Vital 100S/200S [1, 2, 3, 4] -#### Levoit Purifier Core200S/300S/400S and Vital 100S Properties +#### Levoit Purifier Core200S/300S/400S and Vital 100S/200S 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 +`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 Methods +#### Levoit Purifier Core200S/300S/400S and Vital 100S/200S Methods `VeSyncFan.child_lock_on()` Enable child lock @@ -352,9 +353,9 @@ Compatible levels for each model: `VeSyncFan.clear_timer()` - Cancel any running timer -#### Levoit 100S Properties and Methods +#### Levoit Vital 100S/200S Properties and Methods -The Levoit 100S has additional features not available on other models. +The Levoit Vital 100S/200S has additional features not available on other models. `VeSyncFan.set_fan_speed` - The manual fan speed that is currently set (remains constant when in auto/sleep mode) @@ -364,6 +365,8 @@ The Levoit 100S has additional features not available on other models. `VeSyncFan.light_detection_state` - Returns whether light is detected (True/False) +`VeSyncFan.pet_mode()` - Set pet mode **NOTE: Only available on Vital 200S** + `VeSyncFan.set_auto_preference(preference='default', room_size=600)` - Set the auto mode preference. Preference can be 'default', 'efficient' or quiet. Room size defaults to 600 `VeSyncFan.set_light_detection_on()` - Turn on light detection mode diff --git a/src/pyvesync/vesyncfan.py b/src/pyvesync/vesyncfan.py index 5e2e88c..c9303f2 100644 --- a/src/pyvesync/vesyncfan.py +++ b/src/pyvesync/vesyncfan.py @@ -95,12 +95,21 @@ 'models': ['LV-PUR131S', 'LV-RH131S'], 'features': ['air_quality'] }, - 'LAP-V102S-AAS': { - 'module': 'VeSyncVital100S', - 'models': ['LAP-V102S-AASR', 'LAP-V102S-WUS', 'LAP-V102S-WEU', 'LAP-V102S-AUSR'], + 'Vital100S': { + 'module': 'VeSyncVital', + 'models': ['LAP-V102S-AASR', 'LAP-V102S-WUS', 'LAP-V102S-WEU', + 'LAP-V102S-AUSR', 'LAP-V102S-WJP'], 'modes': ['manual', 'auto', 'sleep', 'off'], 'features': ['air_quality'], 'levels': list(range(1, 5)) + }, + 'Vital200S': { + 'module': 'VeSyncVital', + 'models': ['LAP-V201S-AASR', 'LAP-V201S-WJP', 'LAP-V201S-WEU', + 'LAP-V102S-AUSR', 'LAP-V201S-WUS'], + 'modes': ['manual', 'auto', 'sleep', 'off', 'pet'], + 'features': ['air_quality'], + 'levels': list(range(1, 5)) } } @@ -725,32 +734,28 @@ def displayJSON(self) -> str: return json.dumps(sup_val, indent=4) -class VeSyncVital100S(VeSyncAirBypass): - """Levoit Vital 100S Air Purifier Class.""" +class VeSyncVital(VeSyncAirBypass): + """Levoit Vital 100S/200S Air Purifier Class.""" def __init__(self, details: Dict[str, list], manager): super().__init__(details, manager) self.set_speed_level: Optional[int] = None self.auto_prefences: List[str] = ['default', 'efficient', 'quiet'] - def update(self): - """Update Purifier details.""" - self.get_details() + @property + def light_detection(self) -> bool: + """Return true if light detection feature is enabled.""" + return self.details['light_detection_switch'] - def build_api_dict(self, method: str) -> Tuple[Dict, Dict]: - """Return default body for Levoit Vital 100s 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 + @light_detection.setter + def light_detection(self, toggle: bool) -> None: + """Set light detection feature.""" + self.details['light_detection_switch'] = toggle + + @property + def light_detection_state(self) -> bool: + """Return true if light is detected.""" + return self.details['environment_light_state'] def get_details(self) -> None: """Build Levoit 100S Purifier details dictionary.""" @@ -785,6 +790,21 @@ def get_details(self) -> None: else: logger.debug('Error in purifier response') + 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.""" power_switch = dev_dict.get('power_switch', 0) @@ -818,6 +838,10 @@ def build_purifier_dict(self, dev_dict: dict) -> None: else: self.details['auto_preference_type'] = None + def pet_mode(self) -> bool: + """Set Pet Mode for Levoit Vital 200S.""" + return self.mode_toggle('pet') + def set_light_detection(self, toggle: bool) -> bool: """Enable/Disable Light Detection Feature.""" if toggle is True: @@ -852,21 +876,6 @@ def set_light_detection_off(self) -> bool: """Turn off light detection feature.""" return self.set_light_detection(False) - @property - def light_detection(self) -> bool: - """Return true if light detection feature is enabled.""" - return self.details['light_detection_switch'] - - @light_detection.setter - def light_detection(self, toggle: bool) -> None: - """Set light detection feature.""" - self.details['light_detection_switch'] = toggle - - @property - def light_detection_state(self) -> bool: - """Return true if light is detected.""" - return self.details['environment_light_state'] - def toggle_switch(self, toggle: bool) -> bool: """Toggle purifier on/off.""" if not isinstance(toggle, bool): @@ -926,7 +935,7 @@ def set_child_lock(self, toggle: bool) -> bool: return False def set_display(self, toggle: bool) -> bool: - """Levoit Vital 100S Set Display on/off with True/False.""" + """Levoit Vital 100S/200S Set Display on/off with True/False.""" if toggle: toggle_id = 1 else: @@ -1020,7 +1029,7 @@ def clear_timer(self) -> bool: return False def set_auto_preference(self, preference: str = 'default', room_size: int = 600) -> bool: - """Set Levoit Vital 100S auto mode. + """Set Levoit Vital 100S/200S auto mode. Parameters ---------- From c89fe4401cb8cb8ef92e1c8400d0b1da45433728 Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Mon, 17 Jul 2023 09:40:30 -0400 Subject: [PATCH 5/6] Add tests and update readme --- .gitignore | 1 + CONTRIBUTING.md | 68 ++++++-- README.md | 79 ++++++++- setup.py | 3 + src/pyvesync/vesyncfan.py | 42 ++--- src/tests/README.md | 56 +++++-- src/tests/api/vesyncfan/LAP-V102S-AASR.yaml | 167 ++++++++++++++++++++ src/tests/api/vesyncfan/LAP-V201S-AASR.yaml | 167 ++++++++++++++++++++ src/tests/call_json_fans.py | 54 ++++++- src/tests/conftest.py | 22 ++- src/tests/test_all_devices.py | 6 +- src/tests/test_bulbs.py | 6 +- src/tests/test_fans.py | 11 +- src/tests/test_outlets.py | 6 +- src/tests/test_switches.py | 5 +- src/tests/test_x_air_pur.py | 10 +- src/tests/utils.py | 50 ++++-- tox.ini | 2 +- 18 files changed, 673 insertions(+), 82 deletions(-) create mode 100644 src/tests/api/vesyncfan/LAP-V102S-AASR.yaml create mode 100644 src/tests/api/vesyncfan/LAP-V201S-AASR.yaml diff --git a/.gitignore b/.gitignore index c15cd31..d3c97ae 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ test.py tools/__init__.py tools/vesyncdevice.py pyvesync.und +.venv \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be147bc..82373da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,24 +2,28 @@ 1. Git clone the repository -``` -git clone https://github.com/webdjoe/pyvesync +```bash +git clone https://github.com/webdjoe/pyvesync && cd pyvesync ``` 2. Create and activate a separate python virtual environment for pyvesync -``` +```bash +# Check Python version is 3.8 or higher +python3 --version # or python --version or python3.8 --version # Create a new venv python3 -m venv pyvesync-venv # Activate the venv source pyvesync-venv/bin/activate -``` - -3. Install pyvesync in the venv +# or .... +pyvesync-venv\Scripts\activate.ps1 # on powershell +pyvesync-venv\Scripts\activate.bat # on command prompt +# Install development tools +pip install -e .[dev] ``` -pip3 install -e pyvesync/ -``` + +3. Make changes and test in virtual environment If the above steps were executed successfully, you should now have: @@ -33,12 +37,52 @@ run `deactivate`. Install tox, navigate to the pyvesync repository which contains the tox.ini file, and run tox as follows: -``` -pip install tox -cd pyvesync +```bash +# Run all tests and linters tox + +# Run tests, linters separately +tox -e testenv # pytest +tox -e pylint # linting +tox -e lint # flake8 & pydocstrings +tox -e mypy # type checkings ``` +Tests are run based off of the API calls recorded in the [api](src/tests/api) directory. Please read the [Test Readme](src/tests/README.md) for further details on the structure of the tests. + + # Ensure new devices are Integrated in Tests -If you integrate a new device, please read the [testing README](tests/README.md) to ensure that your device is tested. \ No newline at end of file +If you integrate a new device, please read the [testing README](tests/README.md) to ensure that your device is tested. + +## Testing with pytest and Writing API to YAML + +Part of the pytest test are against a library of API calls written to YAML files in the `tests` directory. If you are developing a new device, be aware that these tests will fail at first until you are ready to write the final API. + +There are two pytest command line arguments built into the tests to specify when to write the api data to YAML files or when to overwrite the existing API calls in the YAML files. + +To run a tests for development on existing devices or if you are not ready to write the api calls yet: + +```bash +# Through pytest +pytest + +# or through tox +tox -e testenv # you can also use the environments lint, pylint, mypy +``` + +If developing a new device and it is completed and thoroughly tested, pass the `--write_api` to pytest. Be sure to include the `--` before the argument in the tox command. + +```bash +pytest --write_api + +tox -e testenv -- --write_api +``` + +If fixing an existing device where the API call was incorrect or the api has changed, pass `--write_api` and `overwrite` to pytest. Both arguments need to be provided to overwrite existing API data already in the YAML files. + +```bash +pytest --write_api --overwrite + +tox -e testenv -- --write_api --overwrite +``` diff --git a/README.md b/README.md index 797849a..d46af11 100644 --- a/README.md +++ b/README.md @@ -889,15 +889,80 @@ manager.update() ## Feature Requests -~~If you would like new devices to be added, you will need to capture the packets from the app. The easiest way to do this is by using [Packet Capture for Android](https://play.google.com/store/apps/details?id=app.greyshirts.sslcapture&hl=en_US&gl=US). This works without rooting the device. If you do not have an android or are concerned with adding an app that uses a custom certificate to read the traffic, you can use an Android emulator such as [Nox](https://www.bignox.com/).~~ +Before filing an issue to request a new feature or device, please ensure that you will take the time to test the feature throuroughly. New features cannot be simply tested on Home Assistant. A separate integration must be created which is not part of this library. In order to test a new feature, clone the branch and install into a new virtual environment. -SSL pinning makes capturing packets with Android ~~not feasible anymore~~ harder than before. A system-wide proxy [ProxyDroid](https://play.google.com/store/apps/details?id=org.proxydroid&hl=en) can be used if ssl pinning is disabled [TrustMeAlready](https://github.com/ViRb3/TrustMeAlready). +```bash +mkdir python_test && cd python_test + +# Check Python version is 3.8 or higher +python3 --version # or python --version or python3.8 --version +# Create a new venv +python3 -m venv pyvesync-venv +# Activate the venv on linux +source pyvesync-venv/bin/activate +# or .... +pyvesync-venv\Scripts\activate.ps1 # on powershell +pyvesync-venv\Scripts\activate.bat # on command prompt + +# Install branch to be tested into new virtual environment +pip install git+https://github.com/webdjoe/pyvesync@BRANCHNAME +``` + +Test functionality with a script + +`test.py` + +```python +import sys +import logging +import json +from pyvesync import VeSync + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + +def test_device(): + # Instantiate VeSync class and login + manager = VeSync(user, password, debug=True) + if manager.login() == False + logger("Unable to login") + return + + # Test specific device + # If this were a humidifier and there is only one humidifier/purifier + # You can access it with the device index + fan = manager.fans[0] + # or loop through the fan devices and test for device with "My Device" name + # Use lower() to avoid capitalization issues + my_device_name = "My Device" + fan = None + for dev in manager.fans: + if dev.name.lower() == my_device_name.lower() + fan = dev + if fan == None: + logger.debug("Device not found") + logger.debug("Devices found - \n" + json.dumps(manager._dev_list)) + return + + # Test all device methods and functionality + # Be aware some devices lose internet connectivity if turned off + fan.turn_on() + fan.turn_off() + fan.sleep_mode() + +# Make script runnable from command line +if __name__ == "__main__": + logger.debug("Testing device") + test_device() +... + +``` -Charles Proxy is a proxy that allows you to perform MITM SSL captures on an iOS device. This is the only way to capture packets that I am aware of that is currently possible. +## Device Requests -When capturing packets make sure all packets are captured from the device list, along with all functions that the app contains. The captured packets are stored in text files, please do not capture with pcap format. +SSL pinning makes capturing packets much harder. In order to be able to capture packets, SSL pinning needs to be disabled before running an SSL proxy. Use an Android emulator such as Android Studio, which is available for Windows and Linux for free. Download the APK from APKPure or a similiar site and use [Objection](https://github.com/sensepost/objection) or [Frida](https://frida.re/docs/gadget/). Followed by capturing the packets with Charles Proxy or another SSL proxy application. -After you capture the packets, please redact the `accountid` and `token`. If you feel you must redact other keys, please do not delete them entirely. Replace letters with "A" and numbers with "1", leave all punctuation intact and maintain length. +Be sure to capture all packets from the device list and each of the possible device menus and actions. Please redact the `accountid` and `token` from the captured packets. If you feel you must redact other keys, please do not delete them entirely. Replace letters with "A" and numbers with "1", leave all punctuation intact and maintain length. For example: @@ -921,8 +986,8 @@ After: } ``` -All [contributions](CONTRIBUTING.md) are welcome, please run `tox` before submitting a PR to ensure code is valid. +# Contributing -Ensure new devices are integrated in tests, please review the [testing](tests/README.md) documentation for more information. +All [contributions](CONTRIBUTING.md) are welcome. This project is licensed under [MIT](LICENSE). diff --git a/setup.py b/setup.py index 514c6c7..ec930ad 100644 --- a/setup.py +++ b/setup.py @@ -32,5 +32,8 @@ package_dir={'': 'src'}, zip_safe=False, install_requires=['requests>=2.20.0'], + extras_require={ + 'dev': ['pytest', 'pytest-cov', 'yaml', 'tox'] + }, python_requires='>=3.8', ) diff --git a/src/pyvesync/vesyncfan.py b/src/pyvesync/vesyncfan.py index c9303f2..5cb3855 100644 --- a/src/pyvesync/vesyncfan.py +++ b/src/pyvesync/vesyncfan.py @@ -738,6 +738,7 @@ class VeSyncVital(VeSyncAirBypass): """Levoit Vital 100S/200S Air Purifier Class.""" def __init__(self, details: Dict[str, list], manager): + """Initialize the VeSync Vital 100S/200S Air Purifier Class.""" super().__init__(details, manager) self.set_speed_level: Optional[int] = None self.auto_prefences: List[str] = ['default', 'efficient', 'quiet'] @@ -822,8 +823,10 @@ def build_purifier_dict(self, dev_dict: dict) -> None: self.details['night_light'] = dev_dict.get('night_light', 'off') self.details['display'] = bool(dev_dict.get('screenState', 0)) self.details['display_forever'] = dev_dict.get('display_forever', False) - self.details['light_detection_switch'] = bool(dev_dict.get('lightDetectionSwitch', 0)) - self.details['environment_light_state'] = bool(dev_dict.get('environmentLightState', 0)) + self.details['light_detection_switch'] = bool( + dev_dict.get('lightDetectionSwitch', 0)) + self.details['environment_light_state'] = bool( + dev_dict.get('environmentLightState', 0)) self.details['screen_state'] = bool(dev_dict.get('screenState', 0)) self.details['screen_switch'] = bool(dev_dict.get('screenSwitch', 0)) @@ -834,7 +837,8 @@ def build_purifier_dict(self, dev_dict: dict) -> None: if dev_dict.get('timerRemain') is not None: self.timer = Timer(dev_dict['timerRemain'], 'off') if isinstance(dev_dict.get('autoPreference'), dict): - self.details['auto_preference_type'] = dev_dict.get('autoPreference', {}).get('autoPreferenceType', 'default') + self.details['auto_preference_type'] = dev_dict.get( + 'autoPreference', {}).get('autoPreferenceType', 'default') else: self.details['auto_preference_type'] = None @@ -909,9 +913,9 @@ def toggle_switch(self, toggle: bool) -> bool: self.device_name) return False - def set_child_lock(self, toggle: bool) -> bool: + def set_child_lock(self, mode: bool) -> bool: """Levoit 100S set Child Lock.""" - if toggle: + if mode: toggle_id = 1 else: toggle_id = 0 @@ -928,21 +932,21 @@ def set_child_lock(self, toggle: bool) -> bool: ) if r is not None and Helpers.code_check(r): - self.details['child_lock'] = toggle + self.details['child_lock'] = mode return True logger.debug("Error toggling purifier child lock - %s", self.device_name) return False - def set_display(self, toggle: bool) -> bool: + def set_display(self, mode: bool) -> bool: """Levoit Vital 100S/200S Set Display on/off with True/False.""" - if toggle: - toggle_id = 1 + if mode: + mode_id = 1 else: - toggle_id = 0 + mode_id = 0 head, body = self.build_api_dict('setDisplay') body['payload']['data'] = { - 'screenSwitch': toggle_id + 'screenSwitch': mode_id } r, _ = Helpers.call_api( @@ -953,13 +957,14 @@ def set_display(self, toggle: bool) -> bool: ) if r is not None and Helpers.code_check(r): - self.details['screen_switch'] = toggle + self.details['screen_switch'] = mode return True logger.debug("Error toggling purifier display - %s", self.device_name) return False - def set_timer(self, timer_duration: int, action: str = 'off', method: str = 'powerSwitch') -> bool: + def set_timer(self, timer_duration: int, action: str = 'off', + method: str = 'powerSwitch') -> bool: """Set timer for Levoit 100S. Parameters @@ -1028,7 +1033,8 @@ def clear_timer(self) -> bool: logger.debug("Error setting timer for - %s", self.device_name) return False - def set_auto_preference(self, preference: str = 'default', room_size: int = 600) -> bool: + def set_auto_preference(self, preference: str = 'default', + room_size: int = 600) -> bool: """Set Levoit Vital 100S/200S auto mode. Parameters @@ -1040,7 +1046,8 @@ def set_auto_preference(self, preference: str = 'default', room_size: int = 600) Room size in square feet, by default 600 """ if preference not in self.auto_prefences: - logger.debug("%s is invalid preference - valid preferences are default, efficient, quiet", + logger.debug("%s is invalid preference -" + " valid preferences are default, efficient, quiet", preference) return False head, body = self.build_api_dict('setAutoPreference') @@ -1082,7 +1089,7 @@ def change_fan_speed(self, speed=None) -> bool: return False new_speed = speed else: - if current_speed == speeds[-1] or current_speed == 0: + if current_speed in [speeds[-1], 0]: new_speed = speeds[0] else: current_index = speeds.index(current_speed) @@ -1132,8 +1139,7 @@ def mode_toggle(self, mode: str) -> bool: if mode == 'manual': if self.speed is None or self.speed == 0: return self.change_fan_speed(1) - else: - return self.change_fan_speed(self.speed) + return self.change_fan_speed(self.speed) if mode == 'off': return self.turn_off() diff --git a/src/tests/README.md b/src/tests/README.md index a23374d..c0e0e51 100644 --- a/src/tests/README.md +++ b/src/tests/README.md @@ -9,10 +9,41 @@ The tests primarily run each API call for each device and record the request det The structure of the framework is as follows: 1. `call_json.py` - This file contains general functions and the device list builder. This file does not need to be edited when adding a device. -2. `call_json_DEVICE.py` - This file contains device specific responses such as the `get_details()` response and specific method responses. This file pulls in the device type list from each module. The minimum addition is to add the appropriate response to the `DeviceDetails` class and the device type associated with that response in the `DETAILS_RESPONSES` dictionary. +2. `call_json_DEVICE.py` - This file contains device specific responses such as the `get_details()` response and specific method responses. This file pulls in the device type list from each module. The minimum addition is to add the appropriate response to the `DeviceDetails` class and the device type associated with that response in the `DETAILS_RESPONSES` dictionary. This file also contains the `DeviceDefaults` class which are specific to the device. 3. `test_DEVICE.py` - Each module in pyvesync has it's own test module, typically with one class that inherits the `utils.BaseTest` class. The class has two methods - `test_details()` and `test_methods()` that are parametrized by `utils.pytest_generate_tests` 4. `utils.py` - Contains the general default values for all devices in the `Defaults` class and the `TestBase` class that contains the fixture that instantiates the VS object and patches the `call_api()` method. -5. `conftest.py` - Contains the `pytest_generate_tests` function that is used to parametrize the tests. +5. `conftest.py` - Contains the `pytest_generate_tests` function that is used to parametrize the tests based on all device types listed in the respective modules. + + +## Running the tests + +There are two pytest command line arguments built into the tests to specify when to write the api data to YAML files or when to overwrite the existing API calls in the YAML files. + +To run a tests for development on existing devices or if you are not ready to write the api calls yet: + +```bash +# Through pytest +pytest + +# or through tox +tox -e testenv # you can also use the environments lint, pylint, mypy +``` + +If developing a new device and it is completed and thoroughly tested, pass the `--write_api` to pytest. Be sure to include the `--` before the argument in the tox command. + +```bash +pytest --write_api + +tox -e testenv -- --write_api +``` + +If fixing an existing device where the API call was incorrect or the api has changed, pass `--write_api` and `overwrite` to pytest. Both arguments need to be provided to overwrite existing API data already in the YAML files. + +```bash +pytest --write_api --overwrite + +tox -e testenv -- --write_api --overwrite +``` ## Testing Process @@ -20,7 +51,7 @@ The first test run verifies that all of the devices defined in each pyvesync mod The testing framework takes the approach of verifying the response and request of each API call separately. The request side of the call is verified by recording the request for a mocked call. The requests are recorded into YAML files in the `api` folder of the tests directory, grouped in folders by module and file by device type. -The response side of the API is tested through the use of responses that have been documented in the `call_json` files. +The response side of the API is tested through the use of responses that have been documented in the `call_json` files and the values specified in the `Defaults` and `DeviceDefaults` classes. ## File Structure @@ -40,23 +71,26 @@ The `call_json.py` file contains the functions to build the `get_devices()` resp #### call_json_DEVICE.py -Each device module has it's own call_json file. The structure of the files maintains a consistency for easy test definition. The `DeviceDetails` (SwitchDetails, OutletDetails) class contains the `get_details()` responses for each device as a class attribute. The name of the class attribute does not matter. +Each device module has it's own `call_json` file. The structure of the files maintains a consistency for easy test definition. The `DeviceDetails` (SwitchDetails, OutletDetails) class contains the `get_details()` responses for each device as a class attribute. The name of the class attribute does not matter. The `DETAILS_RESPONSES` dictionary contains the device type as the key and references the `DeviceDetails` class attribute as the value. The `DETAILS_RESPONSES` dictionary is used to lookup the appropriate response for each device type. The responses for device methods are also defined in the `call_json_DEVICE` module. The METHOD_RESPONSES dictionary uses a defaultdict imported from `utils.py` with a simple `{"code": 0, "message": ""}` as the default value. The `METHOD_RESPONSES` dictionary is created with keys of device type and values as the defaultdict object. From here the method responses can be added to the defaultdict object for specific scenarios. ```python -from utils import FunctionResponses +from utils import FunctionResponses from copy import deepcopy device_types = ['dev1', 'dev2'] -method_response = FunctionResponses +# defaultdict with default value - ({"code": 0, "msg": None}, 200) +method_response = FunctionResponses +# Use deepcopy to build the device response dictionary used to test the get_details() method device_responses = {dev_type: deepcopy(method_response) for dev_type in device_types} # Define response for specific device & method +# All response must be tuples with (json response, 200) device_responses['dev1']['special_method'] = ({'response': 'special response', 'msg': 'special method'}, 200) # The default factory can be change for a single device type since deepcopy is used. @@ -66,6 +100,8 @@ device_responses['dev2'].default_factory = lambda: ({'new_code': 0, 'msg': 'succ The method responses can also be a function that accept one argument that contains the kwargs used in the method call. This allows for more complex responses based on the method call. +The test will know whether it is a straight value or function and call it accordingly. + For example, this is the set status response of the valceno bulb: ```python @@ -115,7 +151,7 @@ METHOD_RESPONSES['XYD0001'].update(XYD0001_RESP) ### **`api`** directory with `YAML` files -API requests recorded from the mocked `call_api()` method. The first time a test is run, it will record the request if it does not already exists. The `api` directory contains folders for each module and files for each device_type. The structure of the YAML files is: +API requests recorded from the mocked `call_api()` method. The `api` directory contains folders for each module and files for each device_type. The structure of the YAML files is: **File** `tests/api/switches/esl100.yaml` ```yaml @@ -143,7 +179,7 @@ turn_off: The `utils.py` file contains several helper functions and classes: -**Default values for API responses and requests** +### Default values for API responses and requests The recorded requests are automatically scrubbed with these default values to remove sensitive information and normalize the data. Any new API calls added to `call_json_` files should use the defaults values wherever possible. @@ -167,7 +203,7 @@ device_uuid = Defaults.uuid(dev_type="ESL100") # returns 'ESL100-UUID' device_mac = Defaults.macid(dev_type="ESL100") # returns 'ESL100-MACID' ``` -The `utils` module contains the base class with a fixture that instantiates the VeSync object and patches `call_api()` automatically. +The `utils` module contains the base class with a fixture that instantiates the VeSync object and patches `call_api()` automatically, allowing a return value to be set.. ```python from utils import TestBase, FunctionResponses @@ -207,4 +243,4 @@ class TestBulbs(TestBase): The methods are then parametrized based on those values. For most device additions, the only thing that needs to be added is the device type in the `DETAILS_RESPONSES` and possibly a response in the `METHOD_RESPONSES` dictionary. -See the docstrings in the modules for more information. \ No newline at end of file +See the docstrings in the modules for more information. diff --git a/src/tests/api/vesyncfan/LAP-V102S-AASR.yaml b/src/tests/api/vesyncfan/LAP-V102S-AASR.yaml new file mode 100644 index 0000000..a133288 --- /dev/null +++ b/src/tests/api/vesyncfan/LAP-V102S-AASR.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-V102S-AASR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-V102S-AASR-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-V102S-AASR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-V102S-AASR-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-V102S-AASR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-V102S-AASR-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-V102S-AASR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-V102S-AASR-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-V102S-AASR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-V102S-AASR-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-V102S-AASR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-V102S-AASR-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 diff --git a/src/tests/api/vesyncfan/LAP-V201S-AASR.yaml b/src/tests/api/vesyncfan/LAP-V201S-AASR.yaml new file mode 100644 index 0000000..6fd6860 --- /dev/null +++ b/src/tests/api/vesyncfan/LAP-V201S-AASR.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-V201S-AASR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-V201S-AASR-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-V201S-AASR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-V201S-AASR-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-V201S-AASR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-V201S-AASR-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-V201S-AASR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-V201S-AASR-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-V201S-AASR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-V201S-AASR-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-V201S-AASR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-V201S-AASR-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 diff --git a/src/tests/call_json_fans.py b/src/tests/call_json_fans.py index 8229544..9b5a649 100644 --- a/src/tests/call_json_fans.py +++ b/src/tests/call_json_fans.py @@ -88,7 +88,7 @@ class FanDetails: 'filterLife': { 'change': False, 'useHour': None, - 'percent': 100 + 'percent': FanDefaults.filter_life, }, 'airQuality': 'excellent', 'screenStatus': 'on', @@ -168,7 +168,7 @@ class FanDetails: "code": 0, "result": { "enabled": True, - "filter_life": 3, + "filter_life": FanDefaults.filter_life, "mode": "manual", "level": FanDefaults.fan_level, "air_quality": FanDefaults.air_quality, @@ -192,6 +192,54 @@ class FanDetails: } }, 200) + details_vital100s = ({ + "traceId": Defaults.trace_id, + "code": 0, + "msg": "request success", + "module": None, + "stacktrace": None, + "result": { + "traceId": Defaults.trace_id, + "code": 0, + "result": { + "powerSwitch": Defaults.bin_toggle, + "filterLifePercent": FanDefaults.filter_life, + "workMode": "manual", + "manualSpeedLevel": FanDefaults.fan_level, + "fanSpeedLevel": FanDefaults.fan_level, + "AQLevel": FanDefaults.air_quality, + "PM25": FanDefaults.air_quality_value, + "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 + }, + "scheduleCount": 0, + "timerRemain": 0, + "efficientModeTimeRemain": 0, + "sleepPreference": { + "sleepPreferenceType": "default", + "cleaningBeforeBedSwitch": 1, + "cleaningBeforeBedSpeedLevel": 3, + "cleaningBeforeBedMinutes": 5, + "whiteNoiseSleepAidSwitch": 1, + "whiteNoiseSleepAidSpeedLevel": 1, + "whiteNoiseSleepAidMinutes": 45, + "duringSleepSpeedLevel": 5, + "duringSleepMinutes": 480, + "afterWakeUpPowerSwitch": 1, + "afterWakeUpWorkMode": "auto", + "afterWakeUpFanSpeedLevel": 1 + }, + "errorCode": 0 + } + } + }, 200) + DETAILS_RESPONSES = { 'LV-PUR131S': FanDetails.details_air, @@ -204,6 +252,8 @@ class FanDetails: '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, } FunctionResponses.default_factory = lambda: ({ diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 91250a5..558fa8d 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -1,7 +1,27 @@ +def pytest_addoption(parser): + """Prevent new API's from being written during pipeline tests.""" + parser.addoption( + "--write_api", action="store_true", default=False, + help="run tests without writing API to yaml" + ) + parser.addoption( + "--overwrite", action="store_true", default=False, + help="overwrite existing API in yaml - WARNING do not use unless absolutely necessary" + ) + def pytest_generate_tests(metafunc): if metafunc.cls is None or 'test_x' in metafunc.module.__name__: return - + if metafunc.config.getoption('--write_api'): + write_api = True + else: + write_api = False + if metafunc.config.getoption('--overwrite'): + overwrite = True + else: + overwrite = False + metafunc.cls.overwrite = overwrite + metafunc.cls.write_api = write_api if 'device' in metafunc.cls.__dict__: device = metafunc.cls.__dict__['device'] if device not in metafunc.cls.__dict__: diff --git a/src/tests/test_all_devices.py b/src/tests/test_all_devices.py index 742421d..62af03e 100644 --- a/src/tests/test_all_devices.py +++ b/src/tests/test_all_devices.py @@ -72,7 +72,8 @@ def test_login(self): self.manager.enabled = False assert self.manager.login() all_kwargs = parse_args(self.mock_api) - assert assert_test(self.manager.login, all_kwargs) + assert assert_test(self.manager.login, all_kwargs, None, + self.write_api, self.overwrite) def test_get_devices(self): """Test get_devices() method request and API response.""" @@ -80,7 +81,8 @@ def test_get_devices(self): self.mock_api.return_value = call_json.DeviceList.device_list_response() self.manager.get_devices() all_kwargs = parse_args(self.mock_api) - assert assert_test(self.manager.get_devices, all_kwargs) + assert assert_test(self.manager.get_devices, all_kwargs, None, + self.write_api, self.overwrite) assert len(self.manager.bulbs) == call_json_bulbs.BULBS_NUM assert len(self.manager.outlets) == call_json_outlets.OUTLETS_NUM assert len(self.manager.fans) == call_json_fans.FANS_NUM diff --git a/src/tests/test_bulbs.py b/src/tests/test_bulbs.py index 04c134f..80b1767 100644 --- a/src/tests/test_bulbs.py +++ b/src/tests/test_bulbs.py @@ -152,7 +152,8 @@ def test_details(self, dev_type, method): all_kwargs = parse_args(self.mock_api) # Assert request matches recored request or write new records - assert_test(method_call, all_kwargs, dev_type) + assert_test(method_call, all_kwargs, dev_type, + self.write_api, self.overwrite) # Assert device details match expected values assert bulb_obj.brightness == Defaults.brightness @@ -234,7 +235,8 @@ def test_methods(self, dev_type, method): all_kwargs = parse_args(self.mock_api) # Assert request matches recored request or write new records - assert_test(method_call, all_kwargs, dev_type) + assert_test(method_call, all_kwargs, dev_type, + self.write_api, self.overwrite) def _assert_color(self, bulb_obj): assert math.isclose(bulb_obj.color_rgb.red, DEFAULT_COLOR.rgb.red, rel_tol=1) diff --git a/src/tests/test_fans.py b/src/tests/test_fans.py index b2f1643..e4a0749 100644 --- a/src/tests/test_fans.py +++ b/src/tests/test_fans.py @@ -150,7 +150,7 @@ def test_details(self, dev_type, method): all_kwargs = parse_args(self.mock_api) # Assert request matches recored request or write new records - assert_test(method_call, all_kwargs, dev_type) + assert_test(method_call, all_kwargs, dev_type, self.write_api, self.overwrite) def test_methods(self, dev_type, method): """Test device methods API request and response. @@ -229,7 +229,8 @@ def test_methods(self, dev_type, method): all_kwargs = parse_args(self.mock_api) # Assert request matches recored request or write new records - assert_test(method_call, all_kwargs, dev_type) + assert_test(method_call, all_kwargs, dev_type, + self.write_api, self.overwrite) class TestHumidifiers(TestBase): @@ -329,7 +330,8 @@ def test_details(self, dev_type, method): all_kwargs = parse_args(self.mock_api) # Assert request matches recored request or write new records - assert_test(method_call, all_kwargs, dev_type) + assert_test(method_call, all_kwargs, dev_type, + self.write_api, self.overwrite) def test_methods(self, dev_type, method): """Test device methods API request and response. @@ -403,4 +405,5 @@ def test_methods(self, dev_type, method): all_kwargs = parse_args(self.mock_api) # Assert request matches recored request or write new records - assert_test(method_call, all_kwargs, dev_type) + assert_test(method_call, all_kwargs, dev_type, + self.write_api, self.overwrite) diff --git a/src/tests/test_outlets.py b/src/tests/test_outlets.py index ef95d9a..65ed5ee 100644 --- a/src/tests/test_outlets.py +++ b/src/tests/test_outlets.py @@ -139,7 +139,8 @@ def test_details(self, dev_type, method): all_kwargs = parse_args(self.mock_api) # Assert request matches recored request or write new records - assert_test(method_call, all_kwargs, dev_type) + assert_test(method_call, all_kwargs, dev_type, + self.write_api, self.overwrite) # Assert device attributes match default values assert int(outlet_obj.details['active_time']) == int(Defaults.active_time) @@ -222,7 +223,8 @@ def test_methods(self, dev_type, method): all_kwargs = parse_args(self.mock_api) # Assert request matches recored request or write new records - assert_test(method_call, all_kwargs, dev_type) + assert_test(method_call, all_kwargs, dev_type, + self.write_api, self.overwrite) # Test bad responses self.mock_api.reset_mock() diff --git a/src/tests/test_switches.py b/src/tests/test_switches.py index 962e037..ea564ae 100644 --- a/src/tests/test_switches.py +++ b/src/tests/test_switches.py @@ -139,7 +139,7 @@ def test_details(self, dev_type, method): all_kwargs = parse_args(self.mock_api) # Assert request matches recored request or write new records - assert_test(method_call, all_kwargs, dev_type) + assert_test(method_call, all_kwargs, dev_type, self.write_api, self.overwrite) # Assert device details match expected values assert switch_obj.active_time == Defaults.active_time @@ -225,7 +225,8 @@ def test_methods(self, dev_type, method): all_kwargs = parse_args(self.mock_api) # Assert request matches recored request or write new records - assert_test(method_call, all_kwargs, dev_type) + assert_test(method_call, all_kwargs, dev_type, + self.write_api, self.overwrite) self.mock_api.reset_mock() self.mock_api.return_value = call_json.DETAILS_BADCODE diff --git a/src/tests/test_x_air_pur.py b/src/tests/test_x_air_pur.py index adae7c4..8116c51 100644 --- a/src/tests/test_x_air_pur.py +++ b/src/tests/test_x_air_pur.py @@ -35,7 +35,7 @@ def test_airpur_conf(self): assert fan.uuid == Defaults.uuid(LVPUR131S) def test_airpur_details(self): - """Test 15A get_details().""" + """Test Air Purifier get_details().""" self.mock_api.return_value = CORRECT_DETAILS fan = VeSyncAir131(DEV_LIST_DETAIL, self.manager) fan.get_details() @@ -43,11 +43,11 @@ def test_airpur_details(self): assert fan.device_status == 'on' assert isinstance(dev_details, dict) assert dev_details['active_time'] == 1 - assert fan.filter_life == 100 - assert dev_details['screen_status'] == 'on' + assert fan.filter_life == call_json_fans.FanDefaults.filter_life + assert dev_details['screen_status'] == Defaults.str_toggle assert fan.mode == 'manual' - assert dev_details['level'] == 1 - assert fan.fan_level == 1 + assert dev_details['level'] == call_json_fans.FanDefaults.fan_level + assert fan.fan_level == call_json_fans.FanDefaults.fan_level assert dev_details['air_quality'] == 'excellent' assert fan.air_quality == 'excellent' diff --git a/src/tests/utils.py b/src/tests/utils.py index d240246..0ac04a3 100644 --- a/src/tests/utils.py +++ b/src/tests/utils.py @@ -12,9 +12,10 @@ """ import logging from pathlib import Path +from typing import Any import pytest import yaml -from collections import defaultdict +from collections import defaultdict, namedtuple from unittest.mock import patch from requests.structures import CaseInsensitiveDict from pyvesync.vesync import VeSync @@ -30,7 +31,6 @@ ID_KEYS = ['CID', 'UUID', 'MACID'] - class Defaults: """General defaults for API responses and requests. @@ -68,6 +68,9 @@ class Defaults: color = Color(red=50, green=100, blue=225) brightness = 100 color_temp = 100 + bool_toggle = True + str_toggle = 'on' + bin_toggle = 1 @staticmethod def name(dev_type: str = 'NA'): @@ -223,27 +226,27 @@ def __init__(self, module, dev_type): self._existing_api = None @staticmethod - def _get_path(module): + def _get_path(module) -> Path: yaml_dir = Path.joinpath(Path(__file__).parent, 'api', module) if not yaml_dir.exists(): yaml_dir.mkdir(parents=True) return yaml_dir - def _new_file(self): + def _new_file(self) -> bool: if not self.file.exists(): logger.debug(f'Creating new file {self.file}') self.file.touch() return True return False - def _get_existing_yaml(self): + def _get_existing_yaml(self) -> Any: if self.new_file: return None with open(self.file, 'rb') as f: data = yaml.full_load(f) return data - def existing_api(self, method): + def existing_api(self, method) -> bool: """Check YAML file for existing data for API call. Arguments @@ -262,8 +265,7 @@ def existing_api(self, method): if current_dict is not None: logger.debug(f'API call {method} already exists in {self.file}') return True - return current_dict - return None + return False def write_api(self, method, yaml_dict, overwrite=False): """Write API data to YAML file. @@ -344,6 +346,8 @@ class TestBase: self.caplog : LogCaptureFixture Pytest fixture for capturing logs """ + overwrite = False + write_api = False @pytest.fixture(autouse=True, scope='function') def setup(self, caplog): @@ -373,8 +377,15 @@ def setup(self, caplog): self.mock_api_call.stop() -def assert_test(test_func, all_kwargs, dev_type=None, overwrite=False): - """Test function for tests run on API request data. +def assert_test(test_func, all_kwargs, dev_type=None, + write_api=False, overwrite=False): + """Test pyvesync API calls against existing API. + + Set `write_api=True` to True to write API call data to YAML file. + This will not overwrite existing data unless overwrite is True. + The overwrite argument is only used when API changes, defaults + to false for development testing. `overwrite=True` and `write_api=True` + need to both be set to overwrite existing data. Arguments ---------- @@ -384,8 +395,11 @@ def assert_test(test_func, all_kwargs, dev_type=None, overwrite=False): Dictionary of call_api() arguments dev_type : str, optional Device type being tested + write_api : bool, optional + Write API call data to YAML file, default to False overwrite : bool, optional - Overwrite existing data, default to False + Overwrite existing data ONLY USE FOR CHANGING API, + default to False. Must be set with `write_api=True` Returns ------- @@ -407,7 +421,15 @@ def assert_test(test_func, all_kwargs, dev_type=None, overwrite=False): cls_name = dev_type method_name = test_func.__name__ writer = YAMLWriter(mod, cls_name) - if writer.existing_api(method_name) is not None: - assert writer._existing_api == all_kwargs - writer.write_api(method_name, all_kwargs, overwrite) + if overwrite is True and write_api is True: + writer.write_api(method_name, all_kwargs, overwrite) + return True + if writer.existing_api(method_name) is False: + logger.debug("No existing, API data for %s %s %s", mod, cls_name, method_name) + if write_api is True: + logger.debug("Writing API data for %s %s %s", mod, cls_name, method_name) + writer.write_api(method_name, all_kwargs, overwrite) + else: + logger.debug("Not writing API data for %s %s %s", mod, cls_name, method_name) + assert writer._existing_api == all_kwargs return True diff --git a/tox.ini b/tox.ini index 3ae1190..163f248 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py311, py310, py39, py38, pylint, lint, mypy +envlist = py311, py310, py39, pylint, lint, mypy skip_missing_interpreters = True ignore_basepython_conflict = True From 3263f07be1c7a2438faa129146ba3c1e6a7fb361 Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Mon, 17 Jul 2023 09:55:49 -0400 Subject: [PATCH 6/6] Version bump, add oasismist EU model --- setup.py | 2 +- src/pyvesync/vesyncfan.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index ec930ad..1f811a6 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup( name='pyvesync', - version='2.1.7', + version='2.1.8', 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/vesyncfan.py b/src/pyvesync/vesyncfan.py index 5cb3855..b8a17ae 100644 --- a/src/pyvesync/vesyncfan.py +++ b/src/pyvesync/vesyncfan.py @@ -46,7 +46,7 @@ }, 'OASISMIST': { 'module': 'VeSyncHumid200300S', - 'models': ['LUH-O451S-WUS'], + 'models': ['LUH-O451S-WUS', 'LUH-O451S-WEU'], 'features': ['warm_mist'], 'mist_modes': ['humidity', 'sleep', 'manual'], 'mist_levels': list(range(1, 10)),