Sitelet https://github.com/tv4you2016/pyvesyncV2/commit/c89fe4401cb8cb8ef92e1c8400d0b1da45433728
Skip to content

Commit c89fe44

Browse files
committed
Add tests and update readme
1 parent 4449693 commit c89fe44

18 files changed

Lines changed: 673 additions & 82 deletions

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@ test.py
2929
tools/__init__.py
3030
tools/vesyncdevice.py
3131
pyvesync.und
32+
.venv

‎CONTRIBUTING.md‎

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,28 @@
22

33
1. Git clone the repository
44

5-
```
6-
git clone https://github.com/webdjoe/pyvesync
5+
```bash
6+
git clone https://github.com/webdjoe/pyvesync && cd pyvesync
77
```
88

99
2. Create and activate a separate python virtual environment for pyvesync
1010

11-
```
11+
```bash
12+
# Check Python version is 3.8 or higher
13+
python3 --version # or python --version or python3.8 --version
1214
# Create a new venv
1315
python3 -m venv pyvesync-venv
1416
# Activate the venv
1517
source pyvesync-venv/bin/activate
16-
```
17-
18-
3. Install pyvesync in the venv
18+
# or ....
19+
pyvesync-venv\Scripts\activate.ps1 # on powershell
20+
pyvesync-venv\Scripts\activate.bat # on command prompt
1921

22+
# Install development tools
23+
pip install -e .[dev]
2024
```
21-
pip3 install -e pyvesync/
22-
```
25+
26+
3. Make changes and test in virtual environment
2327

2428
If the above steps were executed successfully, you should now have:
2529

@@ -33,12 +37,52 @@ run `deactivate`.
3337

3438
Install tox, navigate to the pyvesync repository which contains the tox.ini file, and run tox as follows:
3539

36-
```
37-
pip install tox
38-
cd pyvesync
40+
```bash
41+
# Run all tests and linters
3942
tox
43+
44+
# Run tests, linters separately
45+
tox -e testenv # pytest
46+
tox -e pylint # linting
47+
tox -e lint # flake8 & pydocstrings
48+
tox -e mypy # type checkings
4049
```
4150

51+
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.
52+
53+
4254
# Ensure new devices are Integrated in Tests
4355

44-
If you integrate a new device, please read the [testing README](tests/README.md) to ensure that your device is tested.
56+
If you integrate a new device, please read the [testing README](tests/README.md) to ensure that your device is tested.
57+
58+
## Testing with pytest and Writing API to YAML
59+
60+
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.
61+
62+
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.
63+
64+
To run a tests for development on existing devices or if you are not ready to write the api calls yet:
65+
66+
```bash
67+
# Through pytest
68+
pytest
69+
70+
# or through tox
71+
tox -e testenv # you can also use the environments lint, pylint, mypy
72+
```
73+
74+
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.
75+
76+
```bash
77+
pytest --write_api
78+
79+
tox -e testenv -- --write_api
80+
```
81+
82+
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.
83+
84+
```bash
85+
pytest --write_api --overwrite
86+
87+
tox -e testenv -- --write_api --overwrite
88+
```

‎README.md‎

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -889,15 +889,80 @@ manager.update()
889889

890890
## Feature Requests
891891

892-
~~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/).~~
892+
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.
893893

894-
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).
894+
```bash
895+
mkdir python_test && cd python_test
896+
897+
# Check Python version is 3.8 or higher
898+
python3 --version # or python --version or python3.8 --version
899+
# Create a new venv
900+
python3 -m venv pyvesync-venv
901+
# Activate the venv on linux
902+
source pyvesync-venv/bin/activate
903+
# or ....
904+
pyvesync-venv\Scripts\activate.ps1 # on powershell
905+
pyvesync-venv\Scripts\activate.bat # on command prompt
906+
907+
# Install branch to be tested into new virtual environment
908+
pip install git+https://github.com/webdjoe/pyvesync@BRANCHNAME
909+
```
910+
911+
Test functionality with a script
912+
913+
`test.py`
914+
915+
```python
916+
import sys
917+
import logging
918+
import json
919+
from pyvesync import VeSync
920+
921+
logger = logging.getLogger(__name__)
922+
logger.setLevel(logging.DEBUG)
923+
924+
def test_device():
925+
# Instantiate VeSync class and login
926+
manager = VeSync(user, password, debug=True)
927+
if manager.login() == False
928+
logger("Unable to login")
929+
return
930+
931+
# Test specific device
932+
# If this were a humidifier and there is only one humidifier/purifier
933+
# You can access it with the device index
934+
fan = manager.fans[0]
935+
# or loop through the fan devices and test for device with "My Device" name
936+
# Use lower() to avoid capitalization issues
937+
my_device_name = "My Device"
938+
fan = None
939+
for dev in manager.fans:
940+
if dev.name.lower() == my_device_name.lower()
941+
fan = dev
942+
if fan == None:
943+
logger.debug("Device not found")
944+
logger.debug("Devices found - \n" + json.dumps(manager._dev_list))
945+
return
946+
947+
# Test all device methods and functionality
948+
# Be aware some devices lose internet connectivity if turned off
949+
fan.turn_on()
950+
fan.turn_off()
951+
fan.sleep_mode()
952+
953+
# Make script runnable from command line
954+
if __name__ == "__main__":
955+
logger.debug("Testing device")
956+
test_device()
957+
...
958+
959+
```
895960

896-
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.
961+
## Device Requests
897962

898-
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.
963+
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.
899964

900-
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.
965+
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.
901966

902967
For example:
903968

@@ -921,8 +986,8 @@ After:
921986
}
922987
```
923988

924-
All [contributions](CONTRIBUTING.md) are welcome, please run `tox` before submitting a PR to ensure code is valid.
989+
# Contributing
925990

926-
Ensure new devices are integrated in tests, please review the [testing](tests/README.md) documentation for more information.
991+
All [contributions](CONTRIBUTING.md) are welcome.
927992

928993
This project is licensed under [MIT](LICENSE).

‎setup.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,8 @@
3232
package_dir={'': 'src'},
3333
zip_safe=False,
3434
install_requires=['requests>=2.20.0'],
35+
extras_require={
36+
'dev': ['pytest', 'pytest-cov', 'yaml', 'tox']
37+
},
3538
python_requires='>=3.8',
3639
)

‎src/pyvesync/vesyncfan.py‎

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,7 @@ class VeSyncVital(VeSyncAirBypass):
738738
"""Levoit Vital 100S/200S Air Purifier Class."""
739739

740740
def __init__(self, details: Dict[str, list], manager):
741+
"""Initialize the VeSync Vital 100S/200S Air Purifier Class."""
741742
super().__init__(details, manager)
742743
self.set_speed_level: Optional[int] = None
743744
self.auto_prefences: List[str] = ['default', 'efficient', 'quiet']
@@ -822,8 +823,10 @@ def build_purifier_dict(self, dev_dict: dict) -> None:
822823
self.details['night_light'] = dev_dict.get('night_light', 'off')
823824
self.details['display'] = bool(dev_dict.get('screenState', 0))
824825
self.details['display_forever'] = dev_dict.get('display_forever', False)
825-
self.details['light_detection_switch'] = bool(dev_dict.get('lightDetectionSwitch', 0))
826-
self.details['environment_light_state'] = bool(dev_dict.get('environmentLightState', 0))
826+
self.details['light_detection_switch'] = bool(
827+
dev_dict.get('lightDetectionSwitch', 0))
828+
self.details['environment_light_state'] = bool(
829+
dev_dict.get('environmentLightState', 0))
827830
self.details['screen_state'] = bool(dev_dict.get('screenState', 0))
828831
self.details['screen_switch'] = bool(dev_dict.get('screenSwitch', 0))
829832

@@ -834,7 +837,8 @@ def build_purifier_dict(self, dev_dict: dict) -> None:
834837
if dev_dict.get('timerRemain') is not None:
835838
self.timer = Timer(dev_dict['timerRemain'], 'off')
836839
if isinstance(dev_dict.get('autoPreference'), dict):
837-
self.details['auto_preference_type'] = dev_dict.get('autoPreference', {}).get('autoPreferenceType', 'default')
840+
self.details['auto_preference_type'] = dev_dict.get(
841+
'autoPreference', {}).get('autoPreferenceType', 'default')
838842
else:
839843
self.details['auto_preference_type'] = None
840844

@@ -909,9 +913,9 @@ def toggle_switch(self, toggle: bool) -> bool:
909913
self.device_name)
910914
return False
911915

912-
def set_child_lock(self, toggle: bool) -> bool:
916+
def set_child_lock(self, mode: bool) -> bool:
913917
"""Levoit 100S set Child Lock."""
914-
if toggle:
918+
if mode:
915919
toggle_id = 1
916920
else:
917921
toggle_id = 0
@@ -928,21 +932,21 @@ def set_child_lock(self, toggle: bool) -> bool:
928932
)
929933

930934
if r is not None and Helpers.code_check(r):
931-
self.details['child_lock'] = toggle
935+
self.details['child_lock'] = mode
932936
return True
933937

934938
logger.debug("Error toggling purifier child lock - %s", self.device_name)
935939
return False
936940

937-
def set_display(self, toggle: bool) -> bool:
941+
def set_display(self, mode: bool) -> bool:
938942
"""Levoit Vital 100S/200S Set Display on/off with True/False."""
939-
if toggle:
940-
toggle_id = 1
943+
if mode:
944+
mode_id = 1
941945
else:
942-
toggle_id = 0
946+
mode_id = 0
943947
head, body = self.build_api_dict('setDisplay')
944948
body['payload']['data'] = {
945-
'screenSwitch': toggle_id
949+
'screenSwitch': mode_id
946950
}
947951

948952
r, _ = Helpers.call_api(
@@ -953,13 +957,14 @@ def set_display(self, toggle: bool) -> bool:
953957
)
954958

955959
if r is not None and Helpers.code_check(r):
956-
self.details['screen_switch'] = toggle
960+
self.details['screen_switch'] = mode
957961
return True
958962

959963
logger.debug("Error toggling purifier display - %s", self.device_name)
960964
return False
961965

962-
def set_timer(self, timer_duration: int, action: str = 'off', method: str = 'powerSwitch') -> bool:
966+
def set_timer(self, timer_duration: int, action: str = 'off',
967+
method: str = 'powerSwitch') -> bool:
963968
"""Set timer for Levoit 100S.
964969
965970
Parameters
@@ -1028,7 +1033,8 @@ def clear_timer(self) -> bool:
10281033
logger.debug("Error setting timer for - %s", self.device_name)
10291034
return False
10301035

1031-
def set_auto_preference(self, preference: str = 'default', room_size: int = 600) -> bool:
1036+
def set_auto_preference(self, preference: str = 'default',
1037+
room_size: int = 600) -> bool:
10321038
"""Set Levoit Vital 100S/200S auto mode.
10331039
10341040
Parameters
@@ -1040,7 +1046,8 @@ def set_auto_preference(self, preference: str = 'default', room_size: int = 600)
10401046
Room size in square feet, by default 600
10411047
"""
10421048
if preference not in self.auto_prefences:
1043-
logger.debug("%s is invalid preference - valid preferences are default, efficient, quiet",
1049+
logger.debug("%s is invalid preference -"
1050+
" valid preferences are default, efficient, quiet",
10441051
preference)
10451052
return False
10461053
head, body = self.build_api_dict('setAutoPreference')
@@ -1082,7 +1089,7 @@ def change_fan_speed(self, speed=None) -> bool:
10821089
return False
10831090
new_speed = speed
10841091
else:
1085-
if current_speed == speeds[-1] or current_speed == 0:
1092+
if current_speed in [speeds[-1], 0]:
10861093
new_speed = speeds[0]
10871094
else:
10881095
current_index = speeds.index(current_speed)
@@ -1132,8 +1139,7 @@ def mode_toggle(self, mode: str) -> bool:
11321139
if mode == 'manual':
11331140
if self.speed is None or self.speed == 0:
11341141
return self.change_fan_speed(1)
1135-
else:
1136-
return self.change_fan_speed(self.speed)
1142+
return self.change_fan_speed(self.speed)
11371143

11381144
if mode == 'off':
11391145
return self.turn_off()

0 commit comments

Comments
 (0)