pax_global_header00006660000000000000000000000064150743363300014516gustar00rootroot0000000000000052 comment=eb8cecb47ae427304b3aa106a933c9ee6e0bb2a8 webdjoe-pyvesync-eb8cecb/000077500000000000000000000000001507433633000156505ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/.coveragerc000066400000000000000000000013161507433633000177720ustar00rootroot00000000000000# .coveragerc to control coverage.py [run] branch = True omit = src/tests/* [report] # Regexes for lines to exclude from consideration exclude_lines = # Have to re-enable the standard pragma pragma: no cover # Don't complain about missing debug-only code: def __repr__ def __str__ def __eq__ def __hash__ if self\.debug # Disable test coverage for firmware updates def firmware_update def get_config # Don't complain if tests don't hit defensive assertion code: raise AssertionError raise NotImplementedError # Don't complain about abstract methods, they aren't run: @(abc\.)?abstractmethod ignore_errors = True [html] directory = coverage_html_reportwebdjoe-pyvesync-eb8cecb/.github/000077500000000000000000000000001507433633000172105ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/.github/workflows/000077500000000000000000000000001507433633000212455ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/.github/workflows/PRTitle.yaml000066400000000000000000000004731507433633000234600ustar00rootroot00000000000000name: "Semantic Title" on: pull_request_target: types: - opened - edited - synchronize jobs: main: runs-on: ubuntu-latest steps: - uses: amannn/action-semantic-pull-request@v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: wip: truewebdjoe-pyvesync-eb8cecb/.github/workflows/RunTest.yaml000066400000000000000000000024031507433633000235340ustar00rootroot00000000000000name: Run Linting and Unit Tests on: pull_request: branches: - master - dev push: workflow_dispatch: jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install .[dev] - name: Lint with Ruff id: ruff run: | pip install ruff ruff check --output-format=github src/pyvesync continue-on-error: true - name: Run pylint id: pylint run: | pip install pylint pylint src/pyvesync --output-format=text --reports=n continue-on-error: true - name: Test with pytest id: pytest run: | pip install pytest pytest -v - name: Fail if any steps failed run: | [ "${{ steps.ruff.outcome }}" == "failure" ] || [ "${{ steps.pylint.outcome }}" == "failure" ] || [ "${{ steps.pytest.outcome }}" == "failure" ] && exit 1 || exit 0 webdjoe-pyvesync-eb8cecb/.gitignore000066400000000000000000000007531507433633000176450ustar00rootroot00000000000000*.DS_Store build/* .mypy_cache build dist/* src/pyvesync.egg-info/* *.vscode **/*.pyc .coverage htmlcov/ .pytest_cache *__pycache__ cov_html/* cov_html/ test/* test/ /venv/ .idea/ src/tests/test1.py /src/tests/junit/ /.tox/ .tox/.tmp/ .tox/dist/ .tox/log/ tools/* tools/ test.py *.und tools/__init__.py tools/vesyncdevice.py pyvesync.und .venv models.json site/ overrides/ *.log testing_scripts/api_test_editor.py testing_scripts/yamltest.yaml testing_scripts/yamltest copy.yaml creds.json webdjoe-pyvesync-eb8cecb/.pre-commit-config.yaml000066400000000000000000000010731507433633000221320ustar00rootroot00000000000000files: 'src/pyvesync/.*\.py$' ci: autoupdate_commit_msg: "chore: pre-commit autoupdate" repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-yaml - id: trailing-whitespace - id: end-of-file-fixer - id: check-ast - id: check-toml - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.17.1 hooks: - id: mypy - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.12.12 hooks: - id: ruff-check args: [ --fix, --config=ruff.toml ] - id: ruff-format args: [ --config=ruff.toml ] webdjoe-pyvesync-eb8cecb/.pylintrc000066400000000000000000000035041507433633000175170ustar00rootroot00000000000000[MASTER] ignore-paths=^src/tests/.*$, ^test/.*$, ^tools/.*$, ^docs/.*$, ^site/.*$ ignore=test/ load-plugins= pylint.extensions.docparams, pylint.extensions.mccabe generated-members=orjson.* [BASIC] good-names=i,j,k,x,r,e,v,_,b,dt,d [MESSAGES CONTROL] # Reasons disabled: # format - handled by black # locally-disabled - it spams too much # duplicate-code - unavoidable # cyclic-import - doesn't test if both import on load # abstract-class-little-used - prevents from setting right foundation # unused-argument - generic callbacks and setup methods create a lot of warnings # global-statement - used for the on-demand requirement installation # redefined-variable-type - this is Python, we're duck typing! # too-many-* - are not enforced for the sake of readability # too-few-* - same as too-many-* # abstract-method - with intro of async there are always methods missing # inconsistent-return-statements - doesn't handle raise # not-an-iterable - https://github.com/PyCQA/pylint/issues/2311 # unnecessary-pass - readability for functions which only contain pass disable= format, abstract-method, arguments-differ, broad-exception-caught, cyclic-import, duplicate-code, fixme, global-statement, inconsistent-return-statements, invalid-name, locally-disabled, not-an-iterable, too-few-public-methods, too-many-arguments, too-many-branches, too-many-instance-attributes, too-many-lines, too-many-locals, too-many-positional-arguments, too-many-public-methods, too-many-return-statements, too-many-statements, too-complex, wildcard-import, unused-wildcard-import, unnecessary-pass, unused-argument, useless-super-delegation, [REPORTS] #reports=no [TYPECHECK] [FORMAT] expected-line-ending-format=LF max-line-length=90 webdjoe-pyvesync-eb8cecb/CONTRIBUTING.md000066400000000000000000000056311507433633000201060ustar00rootroot00000000000000# Contributing to the pyvesync Library ## Setting up the Development Environment 1. Git clone the repository ```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 # or .... pyvesync-venv\Scripts\activate.ps1 # on powershell pyvesync-venv\Scripts\activate.bat # on command prompt # Install development tools pip install -e .[dev] ``` 3. Make changes and test in virtual environment If the above steps were executed successfully, you should now have: - Code directory `pyvesync`(which we cloned from github) - Python venv directory `pyvesync` (all the dependencies/libraries are contained here) Any change in the code will now be directly reflected and can be tested. To deactivate the python venv, simply run `deactivate`. ## Testing Python with Tox Install tox, navigate to the pyvesync repository which contains the tox.ini file, and run tox as follows: ```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](src/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 ``` webdjoe-pyvesync-eb8cecb/LICENSE000066400000000000000000000020541507433633000166560ustar00rootroot00000000000000MIT License Copyright (c) 2019 Mark Perdue Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. webdjoe-pyvesync-eb8cecb/MANIFEST.in000077500000000000000000000001021507433633000174020ustar00rootroot00000000000000include README.md LICENSE requirements.txt exclude test prune testwebdjoe-pyvesync-eb8cecb/README.md000066400000000000000000000503601507433633000171330ustar00rootroot00000000000000# pyvesync [![build status](https://img.shields.io/pypi/v/pyvesync.svg)](https://pypi.python.org/pypi/pyvesync) [![Build Status](https://dev.azure.com/webdjoe/pyvesync/_apis/build/status/webdjoe.pyvesync?branchName=master)](https://dev.azure.com/webdjoe/pyvesync/_build/latest?definitionId=4&branchName=master) [![Open Source? Yes!](https://badgen.net/badge/Open%20Source%20%3F/Yes%21/blue?icon=github)](https://github.com/Naereen/badges/) [![PyPI license](https://img.shields.io/pypi/l/ansicolortags.svg)](https://pypi.python.org/pypi/ansicolortags/) pyvesync is a library to manage VeSync compatible [smart home devices](#supported-devices) **Check out the new [pyvesync documentation](https://webdjoe.github.io/pyvesync/) for usage and full API details.** ## Supported Product Types 1. Outlets 2. Switches 3. Fans 4. Air Purifiers 5. Humidifiers 6. Bulbs 7. Air Fryers 8. Thermostats See the [supported devices](https://webdjoe.github.io/pyvesync/latest/supported_devices/) page for a complete list of supported devices and device types. ## What's new in pyvesync 3.0 **BREAKING CHANGES** - The release of pyvesync 3.0 comes with many improvements and new features, but as a result there are many breaking changes. The structure has been completely refactored, so please read through the README and thoroughly test before deploying. The goal is to standardize the library across all devices to allow easier and consistent maintainability moving forward. The original library was created 8 years ago for supporting only a few outlets, it was not designed for supporting 20+ different devices. Some of the changes are: - Asynchronous network requests with aiohttp. - Strong typing of all network requests and responses. - Created base classes for all devices for easier `isinstance` checks. - Separated the instantiated devices to a `DeviceContainer` class that acts as a mutable set with convenience methods. - Standardized the API for all device to follow a common naming convention. No more devices with different names for the same functionality. - Implemented custom exceptions and error (code) handling for API responses. - `const` module to hold all library constants - Built the `DeviceMap` class to hold the mapping and features of devices. - COMING SOON: Use API to pull device modes and operating features. See [pyvesync V3](https://webdjoe.github.io/pyvesync/latest/pyvesync3/) for more information on the changes. ### Asynchronous operation Library is now asynchronous, using aiohttp as a replacement for requests. The `pyvesync.VeSync` class is an asynchronous context manager. A `aiohttp.ClientSession` can be passed or created internally. ```python import asyncio import aiohttp from pyvesync.vesync import VeSync async def main(): async with VeSync( username="user", password="password", country_code="US", # Optional - country Code to select correct server session=session, # Optional - aiohttp.ClientSession time_zone="America/New_York", # Optional - Timezone, defaults to America/New_York debug=False, # Optional - Debug output redact=True # Optional - Redact sensitive information from logs ) as manager: # To enable debug mode - prints request and response content for # api calls that return an error code manager.debug = True # Redact mode is enabled by default, set to False to disable manager.redact = False # To print request & response content for all API calls enable verbose mode manager.verbose = True # To print logs to file manager.log_to_file("pyvesync.log") await manager.login() if not manager.enabled: print("Not logged in.") return await manager.get_devices() # Instantiates supported devices in device list, automatically called by login, only needed if you would like updates await manager.update() # Updates the state of all devices # manager.devices is a DeviceContainer object # manager.devices.outlets is a list of VeSyncOutlet objects # manager.devices.switches is a list of VeSyncSwitch objects # manager.devices.fans is a list of VeSyncFan objects # manager.devices.bulbs is a list of VeSyncBulb objects # manager.devices.humidifiers is a list of VeSyncHumid objects # manager.devices.air_purifiers is a list of VeSyncAir objects # manager.devices.air_fryers is a list of VeSyncAirFryer objects # manager.devices.thermostats is a list of VeSyncThermostat objects for outlet in manager.devices.outlets: # The outlet object contain all action methods and static device attributes await outlet.update() await outlet.turn_off() outlet.display() # Print static device information, name, type, CID, etc. # State of object held in `device.state` attribute print(outlet.state) state_json = outlet.dumps() # Returns JSON string of device state state_bytes = orjson.dumps(outlet.state) # Returns bytes of device state # to view the response information of the last API call print(outlet.last_response) # Prints a ResponseInfo object containing error code, # and other response information # Or use your own session session = aiohttp.ClientSession() async def main(): async with VeSync("user", "password", session=session): await manager.login() await manager.update() if __name__ == "__main__": asyncio.run(main()) ``` If using `async with` is not ideal, the `__aenter__()` and `__aexit__()` methods need to be called manually: ```python manager = VeSync(user, password) await manager.__aenter__() ... await manager.__aexit__(None, None, None) ``` pvesync will close the `ClientSession` that was created by the library on `__aexit__`. If a session is passed in as an argument, the library does not close it. If a session is passed in and not closed, aiohttp will generate an error on exit: ```text 2025-02-16 14:41:07 - ERROR - asyncio - Unclosed client session 2025-02-16 14:41:07 - ERROR - asyncio - Unclosed connector ``` ### VeSync Class Signature The VeSync signature is: ```python VeSync( username: str, password: str, country_code: str = DEFAULT_COUNTRY_CODE, # US session: ClientSession | None = None, time_zone: str = DEFAULT_TZ # America/New_York debug: bool = False, redact: bool = True, ) ``` ### Product Types There is a new nomenclature for product types that defines the device class. The `device.product_type` attribute defines the product type based on the VeSync API. The product type is used to determine the device class and module. The currently supported product types are: 1. `outlet` - Outlet devices 2. `switch` - Wall switches 3. `fan` - Fans (not air purifiers or humidifiers) 4. `purifier` - Air purifiers (not humidifiers) 5. `humidifier` - Humidifiers (not air purifiers) 6. `bulb` - Light bulbs (not dimmers or switches) 7. `airfryer` - Air fryers See [Supported Devices](#supported-devices) for a complete list of supported devices and models. ### Custom Exceptions Exceptions are no longer caught by the library and must be handled by the user. Exceptions are raised by server errors and aiohttp connection errors. Errors that occur at the aiohttp level are raised automatically and propogated to the user. That means exceptions raised by aiohttp that inherit from `aiohttp.ClientError` are propogated. When the connection to the VeSync API succeeds but returns an error code that prevents the library from functioning a custom exception inherrited from `pyvesync.logs.VeSyncError` is raised. Custom Exceptions raised by all API calls: - `pyvesync.logs.VeSyncServerError` - The API connected and returned a code indicated there is a server-side error. - `pyvesync.logs.VeSyncRateLimitError` - The API's rate limit has been exceeded. - `pyvesync.logs.VeSyncAPIStatusCodeError` - The API returned a non-200 status code. - `pyvesync.logs.VeSyncAPIResponseError` - The response from the API was not in an expected format. Login API Exceptions - `pyvesync.logs.VeSyncLoginError` - The username or password is incorrect. See [errors](https://webdjoe.github.io/pyvesync/latest/development/utils/errors) documentation for a complete list of error codes and exceptions. The [raise_api_errors()](https://webdjoe.github.io/pyvesync/latest/development/utils/errors/#pyvesync.utils.errors.raise_api_errors) function is called for every API call and checks for general response errors. It can raise the following exceptions: - `VeSyncServerError` - The API connected and returned a code indicated there is a server-side error. - `VeSyncRateLimitError` - The API's rate limit has been exceeded. - `VeSyncAPIStatusCodeError` - The API returned a non-200 status code. - `VeSyncTokenError` - The API returned a token error and requires `login()` to be called again. - `VeSyncLoginError` - The username or password is incorrect. ## Installation Install the latest version from pip: ```bash pip install pyvesync ``` ## Supported Devices ### Etekcity Outlets 1. Voltson Smart WiFi Outlet- Round (7A model ESW01-USA) 2. Voltson Smart WiFi Outlet - Round (10A model ESW01-EU) 3. Voltson Smart Wifi Outlet - Round (10A model ESW03-USA) 4. Voltson Smart Wifi Outlet - Round (10A model ESW10-USA) 5. Voltson Smart WiFi Outlet - Rectangle (15A model ESW15-USA) 6. Two Plug Outdoor Outlet (ESO15-TB) (Each plug is a separate `VeSyncOutlet` object, energy readings are for both plugs combined) ### Wall Switches 1. Etekcity Smart WiFi Light Switch (model ESWL01) 2. Etekcity Wifi Dimmer Switch (ESD16) ### Levoit Air Purifiers 1. LV-PUR131S 2. Core 200S 3. Core 300S 4. Core 400S 5. Core 600S 6. Vital 100S 7. Vital 200S 8. Everest Air ### Etekcity Bulbs 1. Soft White Dimmable Smart Bulb (ESL100) 2. Cool to Soft White Tunable Dimmable Bulb (ESL100CW) ### Valceno Bulbs 1. Valceno Multicolor Bulb (XYD0001) ### Levoit Humidifiers 1. Dual 200S 2. Classic 300S 3. LV600S 4. OasisMist 450S 5. OasisMist 600S 6. OasisMist 1000S ### Cosori Air Fryer 1. Cosori 3.7 and 5.8 Quart Air Fryer ### Fans 1. 42 in. Tower Fan ## Usage ```python import asyncio from pyvesync import VeSync from pyvesync.logs import VeSyncLoginError # VeSync is an asynchronous context manager # VeSync(username, password, debug=False, redact=True, session=None) async def main(): async with VeSync("user", "password") as manager: await manager.login() await manager.update() # Acts as a set of device instances device_container = manager.devices outlets = device_container.outlets # List of outlet instances outlet = outlets[0] await outlet.update() await outlet.turn_off() outlet.display() # Iterate of entire device list for devices in device_container: device.display() if __name__ == "__main__": asyncio.run(main()) ``` If you want to reuse your token and account_id between runs. The `VeSync.auth` object holds the credentials and helper methods to save and load credentials. ```python import asyncio from pyvesync import VeSync from pyvesync.logs import VeSyncLoginError # VeSync is an asynchronous context manager # VeSync(username, password, debug=False, redact=True, session=None) async def main(): async with VeSync("user", "password") as manager: # If credentials are stored in a file, it can be loaded # the default location is ~/.vesync_token await manager.load_credentials_from_file() # or the file path can be passed await manager.load_credentials_from_file("/path/to/token_file") # Or credentials can be passed directly manager.set_credentials("your_token", "your_account_id") # No login needed # await manager.login() # To store credentials to a file after login await manager.save_credentials() # Saves to default location ~/.vesync_token # or pass a file path await manager.save_credentials("/path/to/token_file") # Output Credentials as JSON String await manager.output_credentials() await manager.update() # Acts as a set of device instances device_container = manager.devices outlets = device_container.outlets # List of outlet instances outlet = outlets[0] await outlet.update() await outlet.turn_off() outlet.display() # Iterate of entire device list for devices in device_container: device.display() if __name__ == "__main__": asyncio.run(main()) ``` Devices are stored in the respective lists in the instantiated `VeSync` class: ```python await manager.login() # Asynchronous await manager.update() # Asynchronous # Acts as set with properties that return product type lists manager.devices = DeviceContainer instance manager.devices.outlets = [VeSyncOutletInstances] manager.devices.switches = [VeSyncSwitchInstances] manager.devices.fans = [VeSyncFanInstances] manager.devices.bulbs = [VeSyncBulbInstances] manager.devices.air_purifiers = [VeSyncPurifierInstances] manager.devices.humidifiers = [VeSyncHumidifierInstances] manager.devices.air_fryers = [VeSyncAirFryerInstances] managers.devices.thermostats = [VeSyncThermostatInstances] # Get device by device name dev_name = "My Device" for device in manager.devices: if device.device_name == dev_name: my_device = device device.display() # Turn on switch by switch name switch_name = "My Switch" for switch in manager.devices.switches: if switch.device_name == switch_name: await switch.turn_on() # Asynchronous ``` See the [device documentation](https://webdjoe.github.io/pyvesync/latest/devices/) for more information on the device classes and their methods/states. ## Debug mode and redact To make it easier to debug, there is a `debug` argument in the `VeSync` method. This prints out your device list and any other debug log messages. The `redact` argument removes any tokens and account identifiers from the output to allow for easier sharing. The `redact` argument has no impact if `debug` is not `True`. ```python import asyncio import aiohttp from pyvesync.vesync import VeSync async def main(): async with VeSync("user", "password") as manager: manager.debug = True manager.redact = True # True by default await manager.login() await manager.update() outlet = manager.outlets[0] await outlet.update() await outlet.turn_off() outlet.display() if __name__ == "__main__": asyncio.run(main()) ``` ## Feature Requests 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. ```bash mkdir python_test && cd python_test # Check Python version is 3.11 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.git@BRANCHNAME # Install a PR that has not been merged: pip install git+https://github.com/webdjoe/pyvesync.git@refs/pull/PR_NUMBER/head ``` Test functionality with a script, please adjust methods and logging statements to the device you are testing. `test.py` ```python import asyncio import sys import logging import json from functool import chain from pyvesync import VeSync logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) USERNAME = "YOUR USERNAME" PASSWORD = "YOUR PASSWORD" DEVICE_NAME = "Device" # Device to test async def test_device(): # Instantiate VeSync class and login async with VeSync(USERNAME, PASSWORD, debug=True, redact=True) as manager: await manager.login() # Pull and update devices await manager.update() for dev in manager.devices: # Print all device info logger.debug(dev.device_name + "\n") logger.debug(dev.display()) # Find correct device if dev.device_name.lower() != DEVICE_NAME.lower(): logger.debug("%s is not %s, continuing", self.device_name, DEVICE_NAME) continue logger.debug('--------------%s-----------------' % dev.device_name) logger.debug(dev.display()) logger.debug(dev.displayJSON()) # Test all device methods and functionality # Test Properties logger.debug("Fan is on - %s", dev.is_on) logger.debug("Modes - %s", dev.modes) logger.debug("Fan Level - %s", dev.fan_level) logger.debug("Fan Air Quality - %s", dev.air_quality) logger.debug("Screen Status - %s", dev.screen_status) logger.debug("Turning on") await fan.turn_on() logger.debug("Device is on %s", dev.is_on) logger.debug("Turning off") await fan.turn_off() logger.debug("Device is on %s", dev.is_on) logger.debug("Sleep mode") fan.sleep_mode() logger.debug("Current mode - %s", dev.details['mode']) fan.auto_mode() logger.debug("Set Fan Speed - %s", dev.set_fan_speed) logger.debug("Current Fan Level - %s", dev.fan_level) logger.debug("Current mode - %s", dev.mode) # Display all device info logger.debug(dev.display(state=True)) logger.debug(dev.to_json(state=True, indent=True)) dev_dict = dev.to_dict(state=True) if __name__ == "__main__": logger.debug("Testing device") asyncio.run(test_device()) ... ``` ## Device Requests 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. 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: Before: ```json { "tk": "abc123abc123==3rf", "accountId": "123456789", "cid": "abcdef12-3gh-ij" } ``` After: ```json { "tk": "AAA111AAA111==1AA", "accountId": "111111111", "cid": "AAAAAA11-1AA-AA" } ``` ## Contributing All [contributions](CONTRIBUTING.md) are welcome. This project is licensed under [MIT](LICENSE). ## Contributors This is an open source project and cannot exist without the contributions of its community. Thank you to all the contributors who have helped make this project better! A special thanks for helping with V3 go live: | [
cdninja](https://github.com/cdninja) | [
sapuseven](https://github.com/sapuseven) | [
sdrapha](https://github.com/sdrapha) | | :------- | :------- | :------- | And to all of those that contributed to the project: Made with [contrib.rocks](https://contrib.rocks). webdjoe-pyvesync-eb8cecb/docs/000077500000000000000000000000001507433633000166005ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/docs/assets/000077500000000000000000000000001507433633000201025ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/docs/assets/docs.svg000066400000000000000000000232131507433633000215540ustar00rootroot00000000000000 webdjoe-pyvesync-eb8cecb/docs/assets/icon.png000066400000000000000000002371431507433633000215520ustar00rootroot00000000000000PNG  IHDR  pHYs.#.#x?v IDATx]n}FWüR\9n(~ֺFNNbC˚n(8Q{NBp!6@c3g<c>ֺa p P͢_g5|u|$Y|\ 8#2 dVJiy?$D0 ej aZI7#8mLf `4`3>m3c)It gǖy33)IX?> 8$2)373eS[ChlJt )\ +I\ b$]jh*I$%PڞMpg&2gp6"3#>-shI|' 8#znmwMIn^| Bd1$ Ap{&2\Jgk3P"> Z{JgЌ $2(g6@pA3et?3KdG%>; _Dfp&%ɒn۞%8-sD|o $S4pmgO]3ktCi}< sYhg 8ถO]2dpT3Ff}jhgSn({ 8etfd #_up%">`gO]3>`eSn(_gC̺X3;!8eS3kfUI2Y=۳fp[fv JE|gZpSn(g<53X=`3ǚ@ Vxf%Vx fgVfp4%VfpV Y38g|@p\3 8%$<@ppexkJ7 5>B3u|3] ͬ Sb gg}jdַ=*g< L)0>]]3#4g'88Rc3؋dzpL3Y18Ixp,3#kf}fSIJ; `jhvKJg&8أec s c# `Of\ϔX=@kܧFf}CzVpi3VYmJ7"jhvk} `c3-,sd&4+Yi| TC[jl.DxD3G-sd&4v$%PZpd3瑱-56w ૄfp%3/k$]fp4Sal}>SC[3 >!8 JxO(Yi|n\[ :Dx\ eto$Kal{g 0&p%3fcg%s%<NMp X 8%pܧFf}C )Yi|S%4Dx 8%3gq)"8Oh_ 8_B3|Jg Y1Ip^%3`g~  Jgvf+ >S"<|U ؐ X Q%3`3`=B3g++'4X[ X xJg -sg3.Dx< ݒmE x :ޕoM 9ѕ; ΦDx xKhpv%WB3 c#2 ઄g@cgpUB3>&<ߏtCi}\ )ggpvܧf}C8 Jh:JgpZ38ۘ c#YhCx'"83X156Vgp382#PZ| Hhųgp$ܧf}C.%38 c+>'8=p.Sal}12 vJp{#4JjxVFp{}`7gZ nI@S%pu3hEh c#g2>4 8- +JjxV!8-,sg3Jg f$}Ct83eFhkJgl<)Yi| e$?[S"<jhvKҷ=.mJ7#gfٻ:q{'ơyU}j@Bu8յo-h5gu#o5"gp:3x:"b)=3x C{D`nKg 8Y%fЂ޶3 cf@;RgP%ioC[A膾P|M c!3X1rl\Os@q3-o5[JT!E7g\Sq+;P)a,= 8zynH# l)=p*)ƳTx8 '6E7 h:c3-2m[͖cMJ7sg%f)a,=lIp@;y V8l)=pi)/=KpyknKpNRz!8x:q/=p"BT&'q϶3 ^_WjЂd6Aޜ&LsK1yu3W iK1H<0Ͷs[Kxj!&(ͦ4(/E78ű{,G=(놱[X% ag\ >ِۚ;KVK1)l+'2x v膾5זClP vv\ |($ 3pNStXzRg5P߿@k>C.PЗp-|ǹퟟlCjgu䗳]V$[YP,p 뼄 I#. &Bx)/=g@K1h J"l`?u3]ElM\g#s"2E7؋ h:/y^%.|nBK }! 8ڒ_^J)DhwFp#_ SH g ClEpa4_Kpx:IRtC_z-s}cP?m/ !Τ/xMR^4Ypf3y F\Q^4膾_^Ja h.<кyY*<Sgyנ Юy{ -@zp&3y />&E̒ 7/EZC.E,x (K| np8ktXv ?=y|,R3#:UH!2Z >#E7 K1" h:хzgp%b3~SK J19kU c|AdA|ԩw !8+X%\N«[[s]Eub3)"膱'#>ꑢC@lgLjCc@93h Z$6ʛ"_:Hh (Gt @k{1KJ1!ax3ah ZKx8V5a,;Y1p4Fpǚ""DPu# g&@;gpvڥ%ȗR9xF>GE|Зx Ll/EeZFs{zCt Y^3EJN3H19_^%[n!>;osوM c!@zօsh`38!E~a>Ha߱ NnKHa{ x]4 jKP<63cTLp5Iaz3E88[K }!O3 94KzLt@iuWf@[_K5 JZ%J:[]|@)b3࿦H>p9@A3(Al|JF7e g K1*|,/[zWC(b3 l3'g3.LpGեXf|~ Hp{*\Y,ڑó[8{Agu^KpEmfpz} `/b3 Y*Dg#AlW"o4Ky٭ Dglɇl)"P^z `W3؁ c膱gкЗZ"8-u)"VX>˿Gĭ Dg!Kl-K7s[@DgcL?L҃;rgu^К)a,=p0$E>O+fВ '<7hA/:Yb3hE /_Y[go g()f#|I3xDxGei;-r| 8M c!F38З@p? Dg|Glg"bY_+^%I7sW @t?y ,R,Dg JlgBhLxg|8A|@ 7E7xYn3>3 k(Nhg@Dg3,fK1/ F3.Op5VB3zgP&8zfP# j%8ZfPk$<Z膾p4!6Fx5p93Y Ys@i3.Ep@y[1f@I3.Cp@fP `k3(Et%h JMx%h 6фgpH*<Bp@{fP 4"mфfDg4Gp@;fp$@gpMp~|pB3Dg4Cp(B3Dg4ApyB3`O3NOp9`oB3o-$:g $4Eti 8%ER9(Ax{pJ3Cl{H!4 6E7g8l- `K)/=b3x -rtv+; j $|J3!6W[R92Bt@ugAlH!46/CYvuJ`+3mJsZ1"ؔ }h0x:/q+=lHlgGt膾7{8?/"o5Kc HlngOtL c!p.b3v%83ꗢCoYP?puJs9<8 c0mgIlagOt@y)/=?{w6Pԭ.E^0cf3%@Lm4Ԣ|$@ C (Â2Rf@+ 6p3p9@^0E(KtRLC8b3P.p qP :Zzcyu P zx]sXk ^%6 3":yp@:.!8>3b: ' :YPukgGl@ugKtC|).!<kb3$8n3U3%rGb3%8~32W6vfTMp@Dg#K1J2 Ϻµ3}mb|-=nF#63cг96K8kg#mU3`Lyu @f4Gp@DgH@ĺ\#gAl@gMt:W>r b3%8}3p kLl@gAt 6{v"0'83ڥohkg}r > n &lɵ3 >j`jb'@wgKtPfGp 4]7,@)btKp@DgGp $$6k3 :ؓ@ \;80\5kg{0cl2 fl~ a ĵ3- 1a"^aGpgKtp/Wzf IpDg?Dkg'`X3|U3vC@ Oh##]oj)"f 4-"N Bpc:_K@!db3; IDATEpLƒU3~s  >gDg\5_veXfJUJyqv-;UzߕNlp @RLgA-YkD\J|Ap?4qy_vSA6co=Dg@\5ur >@pm0`;vf@3xЎp h $8G΀1sf@;f< e{Kv $8g΀o`PrDlO+Dg@,( vjpɢ Wr ~Y[DJTIlDg;K1}/{58RXi @%`.@)J1EpuEĩ@1b3(@p%`4b(;5_`hKGпbZ*;p 5(ȅ32BRLga)la]nq*=+&8ZΠGa5{5T ARX>j .@mZb:GrS1".@m\:@\z %b3 gP+Π%)"f0(n Z%6 fcЂTb]nq*=pTׅ,PYlc?@\8t5ZٯAr3h"j KD| 6%3(m|-=u j 6F53( Zeٵ@C+=0`6;6(ɮ [)3d]nq*= Bl r Z-7t(?K@\8ֹt[K1[~@`Ob3h z`[I@@Wf8Btc:_K.fDtϲѭ539vn FtH/s5kg#3E#t~+=PuEĩt;4Yl|)R1b3 g33^};n) ~Koψc3%׬-"Nǀ s3qYx ]xb4^v[D̥ǀB`.h\:c )"f /hTz*<ő/;7#6`D`-z(ǂ~ ᆱ 0c01`03>;H w9%E9 ./ Hp#.}<&co_Y0u` %8щh_|,uDˋ@-L@ &8,hYVz!RDiލ>`p3 =/Pc2`_^L9Z-\6vXtpu7b2_?/%'8!8&:~] e2_ A{//'8%:N)[!hܟWʦy{"S67'6 8>gE]fOT#u4^޽]gT#6!8&:7yO26/x.zO (˒ `t^+`#)\E@tF/ (Ò `$2h.}/Ro 8NVzv",xD eFb3G3~^OlЋOӇB4>ؿq,pK/3t'_-5UКuKlMp,Vb33>\5y.exK*=}\uf@Ug@,J.p P}\-zYr`~Ld"E*>b.9L)vs@g@DgGx.e(. (a]n!:;P-P?,c1GD8Hvs@g@Dg{I1[hX߉'>':; hlk)[!l _Tؒ ؏lOb3 3-$>R- ٫fDf5۱ے h h׳fvDf5ۂ h hףCH|~%E'8!:e3JtںBt9 3/,O n-O5"E B|Mt ӘYVz2"37G~JT@t&6#85Vt&6埃Nq);]=f@g@ƈf@(!E*>n `t92b/PY*<pq^$6&8gt&6Qm.!2~sX A}$6'8rKl# 'EZv }>Ccc5b $GfSf@?\=Hl CpE r 蟗+@}dG EpE53ƕ"g}dG GpE*Lr GvtgXnYd@5K1b)"VtGvtgu/,'H@}dG MpQr" zl%gж:rVr" Zm@}dG3?ձܲfGKTxQrz^dG oe3,hQB3(3hMљ3o-kfSB @;Fgb3r\t&653_3h@Ll wfЊs%\3V͑ǥ҃_86:|Ap3 _3BhH19TLl =fPo)E3_sLk!~ 8vљ jkf09sTz}3gx=:@S`t)ճTx bLlp'fP]JT'EZv `Ll3fP%\33 ע3gϺ?:@ fkR,\t&6x ?Ggb3(m]C3-@Egb3' ^ut&6fCPf/lLl%/q[ <#}^$8{t&6 :̑҃@>fli]NXp PY*<LlОuFjBx ]ؐ h hS5?g@g@92;`3J3.B3`,3*3B3`lsDΩ g@YB3Rgg@B3c5"# 8 8F.hT p/R }Bx@plKhp3`C3`B3R jBx@p#8cU[zTb# b%{*qw _ :/cApfjv k '6[gXGGb36K=<.f|0^Gp<x5d8?pu^BlcX){q ,fA%= `\5Uƒ=zKj:&8vb3ޯ0^G~3mْ=v@oy W8Ut@Of(1NpЃuDĒ=St@pкu"=~0'8 خ|@pPu^"=vrG> 6K=߹pPuDĒ=Tb#j!6JDcK`edW@.@u^"=*qaG :_bjv)1g$8ȰfK hb3k cp3#A~d8 33b3Dg@g@f3 '6 H%8r$: #K=8'b3qO_afEpOlpV`8F] 8 8 u%"C%>X m3038b3Z}ty0p#۱Cx 8 8 C2Wx:OiD.QKi':v%8%6xV A ؍ :_"bɞPFe8%|˟2v?:DD;Q| OWs(ﵲO8:O$D@lJpCt 'D$6v'8#:s z!D%6!8%:T⟫e8%2lϟDh@fagDg@2^C hb3P3 8F qN"4Xb3p3 x2hۻ[Cb3 %:wk&P#4Wg43 2qЦu~4of@*P;ˀ|Llm0N+Eg%6 8"::O?'@jG3W Wb3*3N3h `o4 Z"0ȶhCD\rUu@JD3JmoKVb3Z3~3A5""]?e"BlTNpAtth \? v`o%\18u `ob3 3-3x7Wg ->p ^!6"8$:(bKw%gb393]3="BdngCl_b3I3m3="8 $6%8':Dfe{]";҈ >8mp>b3y33&2m3'6 8":/"3$>?b33?3&2\gOltEpIt@[Df!>Eb3;3_3&2ClHltIpMt@]Df D8N'6&8AtJ`_;! 8GD!L|1f)s~%">:JCl 8 p>3^W"barg_3Wx 8pN3sCChg| 8p^3S"Cvl'6NIp cN%g'pZ3*gNl BtkfgDg':8q f!8 \3Y~ItУڮ #6 #3^#pxxmAp@1S\=h 3ZRb JlWZ!6 oDgs fb3/!: v=߈Bp(@ JlYI8@-f| K кMyKS] z&:l){tip 2:%8 2b YfH>p޽ Vb#ശw-".Ckb3 ;kgo_ 8e Dgg$:Wb/ dgg;xfКu^µ3x gg&:(KlpR3$8k3:1뼄x)':]L_f|Ipc|\G1Sa>%8q3TbJƴKlCg|hf>\;-b3&8Dgԯf޼i oQx>kgIl xȱSlx:jFb3&8u>#=qD%{%6%3CtJlYI_2c:Sb#o3Kt|@e3؋2o#8DgU3]Gl[ ؇x{ =eD%{v3#:=|<eeϠyBpDg<0^G{׈؍ 0N#vKD\gp p.<Λ 8ZafHop{ = :/!:fFpDgGsnwJp@Qb#Flgܙc[%".3H!6 \3w43΢0^G4LfPNlD%{Np@=Dg;z @~d>r%|,~ix?P Kg=(??`/Nl@UgItֲ{ =4y YfTGp@Dg-@ubvF; ZQblVw7tKfTKp@|0Wx@E%w_P5m8e뼄Fb3]nP$z_Hi6$ܟs:|P<Ƒ@ן1vFTAp@]Dg%czlLأK!63X.9{GgPur,AlP1:#6:3%:ۓ9dJiі@CKP-/P?/me@[P5mp(jڥD)w v"6z f4Ap@[Dgr#٫4=j::sjzzRi3䥳%ƈ`^Ll@sgˑW`bKl@f/1f|ws) fy 2#C/?p-"NcFl@gAt8gy`19{8`CS/З_:sc@n|= IDAT:Bl@gg9g!j@Nlv[Dؐ .4XA 6`S9fҙ8Kgj&83Gq쯝^ @g[љ8ث 3x8GqW@g𿺎8^Cub3 8Nl@ث#f >Sq WG!|8_oq?b3 SVt&6>eEgb3!{(tVB5Ҵώc 3+3q/35 8%r3q#':[L3XjA7:[:ӑyr@sD ;5Gp0A[@Qgu5=To:b;+o8Gvkx`-}e:k/`3X8\t&6`mfȲLl+?WEgb3X a ::gqb3Xqt&6`kf3gݯ'5ػ% ѽ1fڗXJ@̝粻$ yc=e{!8>Ɔ(:b5NGpZpLH,0m)+`%3LTd"h }huKXϫh"4'8?2DhtIp@_5s@)"ZDD 1q''8`{3{WhlHpZ-QB`) hS )w G&8`! e~*g,3,SD4K<@o3$8>!2ث)gApl0E{ )yVḰkf fpfSz@533]53g"88Vp L1_=wx-˘;5 Lh:ޅg$88VKD>bͮ1f%A['<9:| z"<) ؇.S'83|lJg{j94+CiS'8 y2fs3^: :$8 bϦ 8E%ЬM1\޲G 8胫f@.@-{tdw@@VoQg@c#Fp5W^Sv)\5gv\5WMgks gkq 2pypD35:F5{[ ){-"J 8 $8XJ%"n3xw u3%:F5{ܛ 53Wz=)[<$8x -˔=`Ogj%{pgM1\޲G e&: ofpSDpwtKpVLjf&: +"dV!:Ŀ$6Ec޸p'-{@/g?Z"=؜ @  @l$:NOp 1\)gy3$6Nt 8p?p*3\fDgi 8AlNt 8>ph3fDga=`5b3`!$6ԪH8)[% co]RUÅ38G=8%Cp 8D%{\8Ol-˔=Y.6!$g3`G[ ?xvKp Y3]{qY{$8ǣ %ZG<c=o1\p ؏VKx ؇=I{!6D= ytDc͏-%yRjЯ=`a_=SC. >y\D%{g@Z#$XMtFpe~Lf؈Q+37S3)-{O3QWlD%{@ xpV]?/ IDATx[Mt3 G \cII^ R lK>j;*Fpiu̞С9 5{@\9R,b3:fOGplo~)+zwVK\g@}ؔ V ;R1{p3`;(+ƕ3`33`K Jp=PZ'%8|X XG5rDpc~(+n.gfr,MpW}X Xf[r X Xl;-2r,Fp,Wrx!8@&WEZ@.g\7+gg|%o׸nГ=7!{p(VK:@oH#W' n3"8UC5<y&8=*<}(};=AQ!3Q#1{3Q%{!8w{TՒ=kR 8kw{]gJ^Cg|`߼%81{/+ { X!8Q%8vGR}vGã7gߔ,ʇG/ @gw|| N*| YIppF>D |Bp|=U jdO`u%{= )k6Q}ku̞@0) 5{9g/ l·I H= d Z'= d V @>5{gDP)3 "1{gZ-\3 "b@J `@=%8ku̞@gjɞCRyg@@g|4dZ-NCp?.7n[ϋljHqd9M؉,&wuJު _m9{Cp35{3=!8Qm9{uEpm9{p>oEp㚳P!8mgD ƴ GpcP!8`'8m}͞Kp@D `LGp@D ;!8`'8ws ;!8`'8 ""g]?ĴsD?@@_c2=Sv_?( t36[޻za}$gOT-"Vgs  (M` ~ ^#0rmb4BpC=!%fP]= /0"@ g0gnQbZJ3 /0"07%})8g0tiYGq3cny [ gyz&<3_`D3O%f@ǫgx| {/EDgExNpӲf h h h h h h ]idЈeЕiqɖOJ^8jS" Чk<^<·@w߸Ŵ#N9{ߛ@= "JL70iD%"J`p3 Sc޲1h= "n^4xv+"1-3l%3*x=Pji)kgYpW>kgIpͫf3^8U3Wp pmmW{^"z#8^߮ %{ *%YI0Gt"0%g+p}1-%"n3@_n}8ǴQrG"8~>kZ.@/| sj #>DDI^| WkZ.!: mexe>OpmLK ,L%w3hS?@r @|nRy64Gp)Gz۴QrG@k=3h "6-R+g -B5h q_ 86ܼn@Ӧe;Z3 ;}^9 nftEkP1Gwx7TJp*Gw˴ ^nwgB3WJ3[]p u33 6WF\*"8x x "89J^7`L^9jno`d3W 3Ö=*PgP@D-{Np|<i)3H%8l^7m`d3U@U\g=* Oi)#:^943ȳe0"dq;+m@p9J rܲ@gaZJh@ 78 W@C\' (`$38[X.rS \%{4ȅnp-%{Bpq*| 438O s@pgq*|E#9Jh38ǖ=:P@gpiY'@\Њ=z'8ݲ@dO h@38ڴ#gEop(=>Jp@kJ U@+iZJPqJ 038Ζ=!8E#eZ -*G33-%{HpǸeg hUЪ-{FpG5{Cpe3v=3xiY'g;횖5{Dp@D ngD 3v3ZW@/gn3v3"Bp@ @/gD 3v3xk,!8`'8]}͞=3v3xfO3v=38”=>Cp7gЦfOÍ4Hp@@ogpkx l5{Hp@D8nV13Zt=q g]s 7aС){Jp@k+=>Jp@;=z&8 @#g=z&8 ;px9{Np瘳@;p9{Np瘳@Ӷ=F 8@g=F 8 0 q9{Bp皳@`38>gOl>30o f|>Gl9{Fp| hg+ggd31gm5{HpY|(L`D3sgi:}͞@.9?$35gx R ){Lp }9y Mp57E IDATD S=F'8:̱f=9' ^9`L^7j.no`D3W*3[\sq 3u3 6WF<*#8:y y $8z9 ^^9O^7j nnw/֜@gP-tFl@|x;g@gІ~+qڜí974@p혽r@_@[|-{wHaFK\^ēy=UqW˶$*ml^ĖD~ +l{mlO`4zsgM^๭G| ʫ<7l XsYMk@pv=>fl /3 6LpXk}$:-g>O| b}5FqO ; 8hiLȣh3ؗcFD)xEXA;Q_Dg>5wf p[gm `΢3n/`g#gc#8Etf9̡&0Ct׸_i`.-}i`Cجx ,:*b3 $:f0%Ktr $8xo7>CpXcDt It0+PgDgBlFp|Dtwb33/?cIp^!8q*pp&:ؠ4p-3-KK"6&8>/oglp5Ug9b3 gwӗ!c$8X/0R_Z 3֏F0~` [:/CL/vmGCpCK_җ6z])V6v`og=nejv=O?F1}9$y2zK˺ nl8Ŷ3[ x$>V3l8Ŷ3?K AprL_u^>uC J/~n5; xF0j '8ɱ³6z)}y x?FsrIe,חS1~'8UK҄ge A>E˺ 3%IOr2vDfT`F3xM`'rJrp Lf$8Kl?mY3T`F3[DE`p{3HppOS֗_3{T`F3G{oӰ)`އe_~g0 Y\wۊGe y`*0#\>J3/O^cp3Hpg0FsDp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dp@Dpeߎ jc@3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 a IDATJ83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 J83* 3N88 䴕na}(1/ye)#K5SI*j!`7Ƒx !80gT3 JpP 3*Cp@%8`g  !80gT3 JpP 3*Cp@%8`g  !80gT3 JpP 3*Cp@%8`g  !80gT3 JpP 3*Cp@%8`g  !80gT3 JpP 3*Cp@%8`g  !80gT3 JpP 3*Cp@%8`g  !80gT3 JpP 3*Cp@%8`g  !80gT3 JpP 3*Cp@%8`g  !80gT3 JpP 3*Cp@%8`g  !80gT3 JpP 3*Cp@%8`g  !80gT3 JpP 3*Cp@%8`g  !80gT3 JpP 3*Cp@%8`g  !80gT3 JpP 3*Cp@%8`g  !80gT3 JpP 3*Cp@%8`g  !80gT3 JpP 3*Cp@%8`g  !80gT3 JpP 3*Cp@%8`}\7ݟw7 ǧw| xvp>}Auț;#HV$8} [(~\H `)`E3nVڏh"4R<g\e Bg"O^7|%@X V$8]R<g|[uݐKv=W#8}ymCxǃC" g:'2[ zY8x x{[ua w>=U. rܔg'`Ex]}WN|1Y8x x˚ٶܪK|Kg} Č[USVޅOY8ٲfG` ``)kf|,VޜOY8,4j\³CVގ3X3=ς\qea[n `m[B3n5 ^Fp翖| ϪΧ.nO_>X eGo +Hh3#8x V"473XX `B3Χg|x3X{ڪGz^el.P}8{ };qp>`w{| s",\z;nK;>t8nT}e Oqd h ~%ЯI3]/=HyՇطeGl;|M)xSz.}@~e*4+cb!w z\8WH\;>jGj-p h gy\5ָv g9\5-9{gUcj`ke"wptjYwD c~kfбvOpKlVwٖ).а%:1_~GĐ;#q_Zw^1+{@Cf]쟘/#bYHp4 IDATZfG͞ 8xIlWz33s9{Fp@Dx=QkQ !8Jp@D5{Dp%3"Bp *c͞w%3K( 3gD +!8Jp@DGp@D8=Z!8 !8Jp@Dpt%{Bp 3=b:&8Gp@D3gD +!8Jpi feʞ-Cp@D3gJ[h ې=3"Bp c/Ch hLCh p%8Jh [=Z#8 "g\ ͗){|;3gWJ[h ʐ=3;eʞx`33"Bp|dOx`#3;CĐ=Z%8 "gO˔=]M =.gOΐ=Z&8~6_ = [  G9GQ@g-޲@peȞ=/S6d8=z 8n= 3veȞ@?23C4d^{t/"81dKC |' {@W;B3~eȞ@7r3C0dGe x12dOaH!85diC xT@޲@g/C}43C4d^ geIH"83_ 4Ļg4e=z@S+HptDp戸ޢf@gu];3h 樫kg0ηlΞ73ǘcv6'm]`Nx p.eΠ@\;VmWͮ33vͱ];@+Ga 7G]_!0/#{vNqi)c`u#B3`.ek=o;  Os%zSs%f sLK]E3~5-) ą3cF]`QkGv3Gu-qq a 4O.πٜ)3㚣/Qsh 4xS A]_'r)=Kߛ@ F'+;`_[hv-6g0H3Gu-!<f&8`Yi9gO9"|0dOyBS ÛHpǕⳒ6uc!P~disDQ3l̦ܕ/N\8{.Q\=kfsl  \\=cw53@+g0?`D3W"-2bh@Kg0?`D3ApCF$8qJ ?30"9J$2 Hp@.QbZJvV9;]`(~gg}s g0?`D3w5l "\18>  \nYbZJ!ufP _eS  Hp1x\B/x HpX.ch2CpCF$8W?>߃Wg0?`D3봜wX׿wPc `(~g| h p#8 "gxO< c 73"Bp .h p#8=%{X36-%{X3"Bp F4- Gp@Dx%{x3"Bp u@[gdOpMKɞ< =S=!8CpcE:Gp=!8Cp#=v%{GpAp4diZJ F7- Ap=%8"0`Sg@ WrΞ^36@>d xUd W%{dO #+6_6_y0=h U@i)6@QCpdj3O^$dZv?=d"8gs-3࿼PAGpdj=3=^,ٴ'obJM3oJf$8 c*6=eWח lZRM.RK]3_jvUrdO`'ߏr =]\mQi9gO&8 #( *=h KaZ =/QCggxЧ= =/= DI^\U rΞCp|=)dC>){ЩdOJLs/._UW5{UŴ'_3-%\9h_"8f _$8 @gw]q/{@]_'%9{/=:@Ņ3`ds 6J@.mos ؏+gY\7v=`P{JLK $yP]_' Ĵ`g_1O}K>!8*lUK7؂6cy| ,<]g:<, Y8ı f?` 7Or>_{y@<]gs[&8y:'y>nhGl Or>NG 钤>vnL79sStC>`og4O}謯=vNG -X53X;j 3Gva ggTvX5("8f ^Y5(&8X kgWK2Z5'8Xy1G'8ؚy곬f#8*ղfIpu3֣Ehi3PEh 38-B3] i `gΘ Kpp3~nL7gG4O,KK2 EppdKxoZEV|g$gάh Y=8kfApuV̚W 3=4kf|DpeY3kw3~N|F-ɜn8מ @1IDf [$y<`$-Ъ`?g$8`"$y:/-a26HpS+hb4? ~( xPJ#5`d.(!C WIENDB`webdjoe-pyvesync-eb8cecb/docs/assets/icon.svg000066400000000000000000000015031507433633000215520ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/docs/authentication.md000066400000000000000000000054331507433633000221460ustar00rootroot00000000000000# VeSync Authentication Module The VeSync Authentication Module provides a clean separation of authentication logic from the main VeSync class, offering improved maintainability, better error handling, and additional features like token persistence. ## Usage Username and password must still be provided when instantiating the `VeSync` class, but a token can be loaded instead of logging in. If the loaded token is not valid, the `login()` method will be automatically called. ### Basic Username/Password Authentication ```python import asyncio from pyvesync import VeSync async def main(): with VeSync(username="example@mail.com", password="password") as manager: # Login success = await manager.login() if not success: print("Login failed!") return print("Login successful!") asyncio.run(main()) ``` ### Loading authentication data The authentication data can be provided to arguments of the `set_credentials()` or `load_credentials_from_file()` methods of the instantiated `VeSync` object. The credentials needed are: `token`, `account_id`, `country_code`, and `region`. ```python import asyncio from pyvesync import VeSync async def main(): with VeSync(username="example@mail.com", password="password") as manager: # Load credentials from a dictionary credentials = { "token": "your_token_here", "account_id": "your_account_id_here", "country_code": "US", "region": "US" } success = await manager.set_credentials(**credentials) # Or load from a file await manager.load_credentials_from_file("path/to/credentials.json") asyncio.run(main()) ``` ### Credential Storage Credentials can be saved to a file or output as a json string. If no file path is provided the credentials will be saved to the users home directory as `.vesync_auth`. The credentials file is a json file that has the keys `token`, `account_id`, `country_code`, and `region`. ```python import asyncio from pathlib import Path from pyvesync import VeSync async def main(): token_file = Path.home() / ".vesync_token" with VeSync(username="example@mail.com", password="password") as manager: # Login and save credentials to file success = await manager.login(token_file_path=token_file) if success: # Save credentials to file manager.save_credentials(token_file) # Output credentials as json string print(manager.output_credentials()) print("Login successful and credentials saved!") else: print("Login failed!") asyncio.run(main()) ``` For a full list of methods and attributes, refer to the [auth](development/auth_api.md) and [vesync](development/vesync_api.md) documentation. webdjoe-pyvesync-eb8cecb/docs/development/000077500000000000000000000000001507433633000211225ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/docs/development/auth_api.md000066400000000000000000000011151507433633000232340ustar00rootroot00000000000000# Documentation for `pyvesync.auth` module This module handles the authentication logic for the VeSync API. It is stored as the `auth` instance attribute of the `VeSync` class. ::: pyvesync.auth.VeSyncAuth handler: python options: group_by_category: true show_root_heading: true show_category_heading: true show_source: false filters: - "!.*_dev_test" - "!set_dev_id" - "!process_devices" - "!remove_old_devices" - "!device_time_check" merge_init_into_class: true show_signature_annotations: true webdjoe-pyvesync-eb8cecb/docs/development/capturing.md000066400000000000000000000141711507433633000234440ustar00rootroot00000000000000# Packet Capturing for New Device Support This document outlines the steps to capture network packets for adding support for new devices in the `pyvesync` library. It is intended for developers who want to extend the library's functionality by integrating additional VeSync devices. Packet captures are required in order to add new devices and functionality. The process outlined below is time consuming and can be difficult. An alternative method is to temporarily share the device. If you would prefer this method, please indicate in an issue or contact the maintainer directly. Sharing a device is done by going to the device settings and selecting "Share Device". Please create a post to notify the maintainers to receive the correct email address to share the device with. Please do not post a device request without being will to either capture packets or share the device. ## Prerequisites 1. **Mumu Emulator**: Download and install the Mumu Android emulator from [Mumu Player](https://www.mumuplayer.com/). This emulator allows you to run Android apps on your computer. Other emulators may work, but Mumu is known to be compatible with Arm64 apk's and allows `adb root` access. 2. **VeSync App**: The latest VeSync app from apkpure or another apk sharing site. 3. **Charles Proxy**: Download and install Charles Proxy from [Charles](https://www.charlesproxy.com/). The 30 day trial is sufficient for this purpose. If you do like the software, support the developer by purchasing a license. 4. **ADB (Android Debug Bridge)**: Ensure you have ADB installed on your system. This is typically included with Android Studio, but can also be installed separately with Android Command Line Tools. This is the site for [Android Studio](https://developer.android.com/studio). Scroll to the "Command Line Tools Only" section to download just the command line tools. Once installed, ensure adb is in your system PATH. You may have to restart your terminal or IDE to pick up the PATH change. The following path is where the `adb` binary is located: `C:\Users\YOUR_USERNAME\AppData\Local\Android\Skd\platform-tools` on Windows or `/home/YOUR_USERNAME/Android/Sdk/platform-tools` on Linux. 5. **frida-server**: Download the latest frida-server from [frida-server](https://github.com/frida/frida/releases). Choose the release that matches the architecture of the MuMu emulator - `frida-server-x.x.x-android-x86_x64.xz`. Extract the `frida-server` binary and place it in the project directory. ## Steps 1. **Set up project directory**: - Create a new directory for the project and place the extracted `frida-server` binary in it. - Move the VeSync (x)apk to the project directory. - Open a terminal in the project directory. - Create a virtual environment and install frida: ```bash python -m venv venv source venv/bin/activate # On Windows cmd use `venv\Scripts\activate` # On powershell use `venv\Scripts\Activate.ps1` pip install frida-tools ``` 2. **Set up Charles Proxy**: - Open Charles Proxy and go to `Proxy` > `Proxy Settings`. Note the HTTP Proxy port (default is 8888). - Go to `Help` > `SSL Proxying` > `Save Root Certificate` > `cert format`. - Save the certificate to the project directory as `cert-der.crt`. 3. **Set up MuMu Emulator**: - Open the Mumu emulator and install the VeSync app by using the down arrow icon on the top right. Select "Install APK" from the bottom of the menu and choose the VeSync apk in the project directory. - Configure the proxy in `System Applications` > `Settings` > `Network & Internet` > `Internet` > Gear icon next to the connected network. If charles is running on the same machine, set the proxy hostname to `localhost:8888` (or the port you noted earlier). - Enable SSL Proxying in Charles by going to `Proxy` > `SSL Proxying Settings` and checking `Enable SSL Proxying`. Add a location with Host: `*` and Port: `*` if not already set. 4. **Configure MuMu to use Charles Proxy**: - Once the MuMu emulator is running, open a new terminal in the project directory and run: ```bash adb connect 127.0.0.1:7555 adb root adb push cert-der.crt /data/local/tmp/ adb push frida-server /data/local/tmp/ adb shell # This should bring up the android emulator command line cd /data/local/tmp chmod +x frida-server ./frida-server ``` - **LEAVE THIS TERMINAL OPEN**. There will be no output from the final command. 5. **Run the frida script**: - With Mumu and the frida-server terminal running, open a separate terminal in the project directory, and ensure that the virtual environment is activated. - Run the following command to start frida with the VeSync app: ```bash frida -U --codeshare akabe1/frida-multiple-unpinning -f com.etekcity.vesyncplatform ``` - This will start the VeSync app and allow charles to capture the packets. You should see output in the terminal indicating that frida has attached to the process and the app will open in the emulator. - Login to your VeSync account in the app and check that charles is able to capture and decode the packages. The url should start with `https://smartapi.vesync.com` or `https://smartapi.vesync.eu`. On occasion it may start with `https://smartapi.vesyncapi.com` or `https://smartapi.vesyncapi.eu`. 6. **Run actions in the VeSync app**: - Perform all of the actions, including timers and schedulers. Ensure that after each action you go back to the device list and then back into the device. This ensures that the status of the device is captured after each action. - If you have multiple devices, perform actions on each device. 7. **Save the Charles session**: - Once all actions have been performed, stop the frida process by pressing `CTRL+C` in the frida terminal. - In Charles, go to `File` > `Save Session As...` and save the session as `vesync_session.chls` in the project directory. 8. **Share the capture**: - **DO NOT** post the capture in an issue. Please create an issue or comment on an issue to notify the maintainers that you have a capture ready to share. - Files can be shared via discord or email to webdjoe at gmail. webdjoe-pyvesync-eb8cecb/docs/development/constants.md000066400000000000000000000006251507433633000234630ustar00rootroot00000000000000# VeSync Constants ::: pyvesync.const options: show_root_heading: true members_order: "source" members: true show_source: true filters: - "!.*_dev_test" - "!set_dev_id" - "!process_devices" - "!remove_old_devices" - "!device_time_check" - "!__doc__*" - "!__module__" - "!__class__" - "!__format__" webdjoe-pyvesync-eb8cecb/docs/development/contributing.md000066400000000000000000000056221507433633000241600ustar00rootroot00000000000000# Contributing to pyvesync Contributions are welcome! Please follow the guidelines below to ensure a quick and smooth review process. Uses the [pre-commit](https://pre-commit.com/) framework to manage and maintain code quality. This is automatically run on `commit` to check for formatting and linting issues by the `pre-commit.ci` service. Running this manually is not required, but recommended to ensure a clean commit: ```bash pre-commit run ``` **NOTE:** Changes must be staged in order for this to work properly. ## Code Style ### General Style Guidelines - Single quotes for strings, except when the string contains a single quote or is a docstring. - Use f-strings for string formatting. - Use type hinting for function signatures and variable declarations. - Docstrings for all public classes, methods and functions. Not required for inherited methods and properties. - Constants should be stored in the `const` module, there should be no hardcoded strings or numbers in the code. - Line length is limited to 90 characters. - Classes and public variables must be camel cased. - Local variables, methods and properties must be snake cased. - Imports must be sorted and grouped by standard library, third party and local imports. ### Device Method and Attribute Naming - All states specific to a device type must be stored in the `DeviceState` class in the base device type module. For example, `SwitchState` for switches, `PurifierState` for purifiers, etc. - All device properties and methods are to be created in the specific device type base class, not in the implementation device class. - All device methods that set one or the other binary state must be named `turn__on()` or `turn__off()`. For example, `turn_on()`, `turn_off()`, `turn_child_lock_on()`, `turn_child_lock_off()`. - The `turn_on()` and `turn_off()` are specific methods that use the `toggle_switch()` method. Any method that toggles a binary state must be named `toggle_()`. For example, `toggle_lock()`, `toggle_mute()`, `toggle_child_lock()`. - Methods that set a specific state that is not on/off must be named `set_()`. For example, `set_brightness()`, `set_color()`, `set_temperature()`. ## Testing and Linting For convenience, the `tox` can be used to run tests and linting. This requires `tox` to be installed in your Python environment. To run all tests and linting: ```bash tox ``` Specific test environments: ```bash tox -e py38 # Run tests with Python 3.8 tox -e py39 # Run tests with Python 3.9 tox -e py310 # Run tests with Python 3.10 tox -e py311 # Run tests with Python 3.11 tox -e lint # Run pylint checks tox -e ruff # Run ruff checks tox -e mypy # Run mypy type checks ``` ## Requests to Add Devices Please see [CAPTURING.md](CAPTURING.md) for instructions on how to capture the necessary information to add a new device. webdjoe-pyvesync-eb8cecb/docs/development/data_models.md000066400000000000000000000155651507433633000237340ustar00rootroot00000000000000# Data Models Data models are used to strongly type and verify API request and response structure. All API calls require a data model to be passed in as a parameter. Each module in the `pyvesync.models` module is for a specific product type or API call. The dataclasses inherit from mashumaro's `DataClassORJSONMixin` which allows for easy serialization and deserialization of the data models, as well as providing a discrimintor for subclasses. The `bypassv2_models` module is a generic mixin for the bypassv2 API calls. - [Data Models](#data-models) - [Data Models Module](#data-models-module) - [Base Models](#base-models) - [BypassV2 Models](#bypassv2-models) - [VeSync General API Models](#vesync-general-api-models) - [Bulb Models](#bulb-models) - [Fan Models](#fan-models) - [Humidifier Models](#humidifier-models) - [Outlet Models](#outlet-models) - [Purifier Models](#purifier-models) - [Switch Models](#switch-models) - [Fryer Models](#fryer-models) - [Thermostat Models](#thermostat-models) ## Data Models Module ::: pyvesync.models options: members: false heading_level: 3 show_root_heading: false show_root_toc_entry: false ## Base Models ::: pyvesync.models.base_models options: show_signature_annotations: true heading_level: 3 show_submodules: false signature_crossrefs: true show_root_heading: false show_category_heading: true show_root_toc_entry: true inherited_members: false show_source: true filters: - "!Config" - "!^__.*" - "!^_.*" - "!IntFlag" - "!StrFlag" - "!RGB" - "!Color" - "!HSV" ## BypassV2 Models ::: pyvesync.models.bypass_models options: show_signature_annotations: true show_submodules: false heading_level: 3 signature_crossrefs: true show_root_heading: false show_category_heading: true show_root_toc_entry: true inherited_members: false show_source: true filters: - "!Config" - "!^__.*" - "!^_.*" - "!IntFlag" - "!StrFlag" - "!RGB" - "!Color" - "!HSV" ## VeSync General API Models ::: pyvesync.models.vesync_models options: show_signature_annotations: true show_submodules: false heading_level: 3 signature_crossrefs: true show_root_heading: false show_category_heading: true show_root_toc_entry: true inherited_members: false show_source: true filters: - "!Config" - "!^__.*" - "!^_.*" - "!IntFlag" - "!StrFlag" - "!RGB" - "!Color" - "!HSV" ## Bulb Models ::: pyvesync.models.bulb_models options: show_signature_annotations: true show_submodules: false signature_crossrefs: true show_root_heading: false heading_level: 3 show_category_heading: true show_root_toc_entry: true inherited_members: false show_source: true filters: - "!Config" - "!^__.*" - "!^_.*" - "!IntFlag" - "!StrFlag" - "!RGB" - "!Color" - "!HSV" ## Fan Models ::: pyvesync.models.fan_models options: show_signature_annotations: true show_submodules: false signature_crossrefs: true heading_level: 3 show_root_heading: false show_category_heading: true show_root_toc_entry: true inherited_members: false show_source: true filters: - "!Config" - "!^__.*" - "!^_.*" - "!IntFlag" - "!StrFlag" - "!RGB" - "!Color" - "!HSV" ## Humidifier Models ::: pyvesync.models.humidifier_models options: show_signature_annotations: true show_submodules: false signature_crossrefs: true heading_level: 3 show_root_heading: false show_category_heading: true show_root_toc_entry: true inherited_members: false show_source: true filters: - "!Config" - "!^__.*" - "!^_.*" - "!IntFlag" - "!StrFlag" - "!RGB" - "!Color" - "!HSV" ## Outlet Models ::: pyvesync.models.outlet_models options: show_signature_annotations: true show_submodules: false heading_level: 3 signature_crossrefs: true show_root_heading: false show_category_heading: true show_root_toc_entry: true inherited_members: false show_source: true filters: - "!Config" - "!^__.*" - "!^_.*" - "!IntFlag" - "!StrFlag" - "!RGB" - "!Color" - "!HSV" ## Purifier Models ::: pyvesync.models.purifier_models options: show_signature_annotations: true show_submodules: false heading_level: 3 signature_crossrefs: true show_root_heading: false show_category_heading: true show_root_toc_entry: true inherited_members: false show_source: true filters: - "!Config" - "!^__.*" - "!^_.*" - "!IntFlag" - "!StrFlag" - "!RGB" - "!Color" - "!HSV" ## Switch Models ::: pyvesync.models.switch_models options: show_signature_annotations: true show_submodules: false heading_level: 3 signature_crossrefs: true show_root_heading: false show_category_heading: true show_root_toc_entry: true inherited_members: false show_source: true filters: - "!Config" - "!^__.*" - "!^_.*" - "!IntFlag" - "!StrFlag" - "!RGB" - "!Color" - "!HSV" ## Fryer Models ::: pyvesync.models.fryer_models options: show_signature_annotations: true show_submodules: false heading_level: 3 signature_crossrefs: true show_root_heading: false show_category_heading: true show_root_toc_entry: true inherited_members: false show_source: true filters: - "!Config" - "!^__.*" - "!^_.*" - "!IntFlag" - "!StrFlag" - "!RGB" - "!Color" - "!HSV" ## Thermostat Models ::: pyvesync.models.thermostat_models options: show_signature_annotations: true show_submodules: false heading_level: 3 signature_crossrefs: true show_root_heading: false show_category_heading: true show_root_toc_entry: true inherited_members: false show_source: true filters: - "!Config" - "!^__.*" - "!^_.*" - "!IntFlag" - "!StrFlag" - "!RGB" - "!Color" - "!HSV" webdjoe-pyvesync-eb8cecb/docs/development/device_container.md000066400000000000000000000006731507433633000247530ustar00rootroot00000000000000# Device Container ::: pyvesync.device_container options: show_root_heading: true members: - DeviceContainerInstance ::: pyvesync.device_container.DeviceContainer options: show_root_heading: true members: true members_order: source ::: pyvesync.device_container._DeviceContainerBase options: show_root_heading: true members: true members_order: source webdjoe-pyvesync-eb8cecb/docs/development/device_map.md000066400000000000000000000005211507433633000235360ustar00rootroot00000000000000# VeSync device_map configuration ::: pyvesync.device_map options: show_root_heading: true force_inspection: true members_order: source show_if_no_docstring: true show_source: true group_by_category: true webdjoe-pyvesync-eb8cecb/docs/development/index.md000066400000000000000000000577741507433633000225770ustar00rootroot00000000000000# pyvesync Library Development This is a community driven library, so contributions are welcome! Due to the size of the library and variety of API calls and devices there are guidelines that need to be followed to ensure the continued development and maintanability. There is a new nomenclature for product types that defines the device class. The `device.product_type` attribute defines the product type based on the VeSync API. The product type is used to determine the device class and module. The currently supported product types are: 1. `outlet` - Outlet devices 2. `switch` - Wall switches 3. `fan` - Fans (not air purifiers or humidifiers) 4. `purifier` - Air purifiers (not humidifiers) 5. `humidifier` - Humidifiers (not air purifiers) 6. `bulb` - Light bulbs (not dimmers or switches) 7. `airfryer` - Air fryers ## Architecture The `pyvesync.vesync.VeSync` class, also referred to as the `manager` is the central control for the entire library. This is the only class that should be directly instantiated. The `VeSync` instance contains the authentication information and holds the device objects. The `VeSync` class has the method `async_call_api` which should be used for all API calls. It is as you might has guessed asynchronous. The session can either be passed in when instantiating the manager or generated internally. Devices have a base class in the `pyvesync.base_devices` module. Each device type has a separate module that contains the device class and the API calls that are specific to that device type. The device classes inherit from the `VeSyncBaseDevice` and `VeSyncToggleDevice` base classes and implement the API calls for that device type. The base class for the device state is also located in the `base_devices` module. The device state is a dataclass that contains all the attributes for that device type. The state is updated when `update()` is called. All attributes should be kept in the device base state class and attributes that are not supported by all models should have a `IntFlag.NOT_SUPPORTED` or `StrFlag.NOT_SUPPORTED` value. ## Naming conventions All attributes and methods should be named using snake_case and follow the naming convention outlined below. ### On/Off States States that have a string value, such as "on" or "off", should be appended with `_status`. For example, `device_status` or `connection_status`. The use of bool for on/off state attributes should be avoided. The `status` attributes should use a `StrEnum` constant from the `pyvesync.const` module. The `status` attributes should be set to `StrEnum.NOT_SUPPORTED` if the feature is not supported by all devices. The general method to act on an on/off attribue should be `toggle_` and accept a boolean value. The method should be named `toggle_` and the attribute should be set to the appropriate value. For example, `toggle_power` or `toggle_light_detection`. The method should accept a boolean value and set the attribute to the appropriate value. The methods that spefically turn a device or or off should be named `turn_on_` or `turn_off_`. The attribute should be set to the appropriate value. For example, `turn_on_power` or `turn_off_light_detection`. The method should accept a boolean value and set the attribute to the appropriate value. With the exception of Air Fryers, all devices inherit from the `VeSyncToggleDevice` class, which includes the `toggle_power`, `turn_on` and `turn_off` methods. ### Named Modes and Levels For modes or levels, such as `fan_level` or `mode` attributes should use a `StrEnum` defined in the `pyvesync.const` module. To change the mode or level, the methods should be named as `set_` and accept a string value. The method should be named `set_` and the attribute should be set to the appropriate value. For example, `set_fan_level` or `set_mode`. The method should accept a string value and set the attribute to the appropriate value. ## Library Utils Module There are several helper methods and utilities that are provided for convenience: ### helpers module The helpers module contains the `Validators`, `Helpers` and `Timer` classes that are used throughout the library. The `Validators` class contains methods to validate the input values for the API calls. The `Helpers` class contains methods to help with the API calls and the `Timer` class is used to handle timers and delays. ## STRONG Typing of responses and requests All data coming in or going out should be strongly typed by a dataclass or TypedDict. The data models are located in the `pyvesync.models` module. The `pyvesync.models.base_models` contains the `DefaultValues` class that is used to hold the constant values that do not change with each API call. It can also contain class or static methods that do not accept any arguments. The helper function `pyvesync.helpers.Helpers.get_class_attributes` is used to fill the values of the API calls by looking up class attributes, such as `token` in the `VeSync` instance and `cid` in the device instance. It accepts a list of keys and pulls the values of each key as they are found in the passed object: ```python keys = ['token', 'accountId', 'cid', 'timeZone', 'countryCode'] manager_dict = get_class_attributes(manager, keys) # request_dict = {"token": "AUTH_TOKEN", "accountId": "ACCOUNT_ID"} device_dict = get_class_attributes(device, keys) # device_dict = {"cid": "DEVICE CID"} ``` It can also handle class methods for items such as traceId which need to be calculated each API call: ```python keys = ['traceId'] default_dict = get_class_attributes(DefaultValues, keys) # {"traceId": "TIMESTAMP"} # It can also handle underscores and different capitalization schemes # It will always return the format of the key being passed in: keys = ["trace_id", "AccountID"] request_dict = get_class_attributes(DefaultValues, keys) # {"trace_id": "TIMESTAMP"} manager_dict = get_class_attributes(manager, keys) # {"AccountID": "ACCOUNT ID"} ``` ### Base Models The data models are located in the `models` folder in separate models. The `base_model` module contains a dataclass holding the default values that do not change between library changes. The `base_model` module is imported into all other models to ensure that the default values stay consistent. The `base_model` module also contains base models that can be inherited for easy configuration and common fields. ```python @dataclass class ResponseBaseModel(DataClassORJSONMixin): """Base response model for API responses.""" class Config(BaseConfig): """orjson config for dataclasses.""" orjson_options = orjson.OPT_NON_STR_KEYS forbid_extra_keys = False ``` `ResponseCodeModel` - Inherits from `ResponseBaseModel` and contains the base keys in most API responses: ```python @dataclass class ResponseCodeModel(ResponseBaseModel): """Model for the 'result' field in response.""" traceId: str code: int msg: str | None ``` ### Request and Response Serialization/Deserialization with Mashumaro pyvesync uses Mashumaro with orjson for data models and serializing/deserializing data structures. The models are located in the `pyvesync.data_models` model. These models should be used to deserialize all API responses. The `base_model.DefaultValues` should be used to define constant and calculated fields throughout each API call. There are additional helper base classes to simplify models: `ResponseBaseModel` - this contains configuration overrides to allow Mashumaro to deserialize non-string keys and allows extra keys in the response. Only the keys that are needed can be defined. ```python @dataclass class ResponseBaseModel(DataClassORJSONMixin): """Base response model for API responses.""" class Config(BaseConfig): """orjson config for dataclasses.""" orjson_options = orjson.OPT_NON_STR_KEYS forbid_extra_keys = False ``` `ResponseCodeModel` - Inherits from `ResponseBaseModel` and contains the base keys in most API responses: ```python @dataclass class ResponseCodeModel(ResponseBaseModel): """Model for the 'result' field in response.""" traceId: str code: int msg: str | None ```` Models for each device should be kept in the `data_models` folder with the appropriate device name: - `bulb_models` - `humidifier_models` - `purifier_models` - `outlet_models` - `switch_models` - `fan_models` There are multiple levels to some requests with nested dictionaries. These must be defined in different classes: ```python # The ResponseESL100CWDeviceDetail inherits from ResponseCodeModel @dataclass class ResponseESL100CWDeviceDetail(ResponseCodeModel): """Response model for Etekcity bulb details.""" result: ResponseESL100CWDetailResult @dataclass class ResponseESL100CWLight(ResponseBaseModel): """ESL100CW Tunable Bulb Device Detail Response.""" action: str brightness: int = 0 colorTempe: int = 0 @dataclass class ResponseESL100CWDetailResult(ResponseBaseModel): """Result model for ESL100CW Tunable bulb details.""" light: ResponseESL100CWLight ``` This model parses the following json response: ```python from pyvesync.data_models.bulb_models import ResponseESL100CWDeviceDetail import orjson api_response = { "traceId": "12345678", "code": 0, "msg": "success", "module": None, "stacktrace": None, "result": { "light": { "action": "on", "brightness": 5, "colorTempe": 0 } } } api_response_dict, status_code = await manager.async_call_api( "/v1/endpoint", "post", request_body, request_headers ) response_model = ResponseESL100CWDeviceDetail.from_dict(response_bytes) result = response_model.result light_model = result.light print(light_model.action) # prints: on print(light_model.brightness) # prints: 5 print(light_model.colorTempe) # prints: 0 ``` ## Constants All constants should be located in the `pyvesync.const` module, including default values. There should not be any constants defined in the code. Use `enum.StrEnum` or `enum.IntEnum` for Enum values. All device modes and feature names are defined in this module. `IntEnum` and `StrEnum` are preferred for device status and state because boolean only allows for two states. There is no way to tell if the state is not yet known or unsupported. The `IntFlag` and `StrFlag` classes are used to define attributes in the state class that may not be supported by all devices. ```python class IntFlag(IntEnum): """Integer flag to indicate if a device is not supported. This is used by data models as a default value for feature attributes that are not supported by all devices. The default value is -999. """ NOT_SUPPORTED = -999 def __str__(self) -> str: """Return string representation of IntFlag.""" return str(self.name) class StrFlag(StrEnum): """String flag to indicate if a device is not supported. This is used by data models as a default value for feature attributes that are not supported by all devices. The default value is "not_supported". """ NOT_SUPPORTED = "not_supported" ``` The string states that support 'on' and 'off' have helper methods that allow for easy conversion from bool and int values: ```python from pyvesync.const import DeviceStatus api_int = 1 api_bool = True device.state.device_status = DeviceStatus.from_int(api_int) assert device.state.device_status == DeviceStatus.ON device.state.device_status = DeviceStatus.from_bool(api_bool) assert device.state.device_status == DeviceStatus.ON api_int = int(device.state.device_status) assert api_int == 1 api_bool = bool(device.state.device_status) assert api_bool == True ``` **Note that this only works for on/off values.** ## Device Map All features and configuration options for devices are held in the `pyveysnc.device_map` module. Older versions of pyvesync held the device configuration in each device module, all of these have moved to the `device_map` module. Each product type has a dataclass structure that is used to define all of the configuration options for each type. The `device_map.get_device_config(device_type: str)` method is used to lookup the configuration dataclass instance by the `deviceType` value in the device list response. There are also methods for each device to return the device configuration with the correct type. For example, `get_outlet_config()` returns the configuration for the outlet device. The configuration is a dataclass that contains all of the attributes for that device type. The configuration is used to define the attributes in the device state class. ## Authentication The two primary authentication attributes are `manager.token` and `manager.account_id`. These are used to authenticate all API calls, in combination with other attributes. The `country_code` and `time_zone` attributes are also used in the majority of calls. They are retrieved when calling the `login()` method. ## Device Container Devices are held in the `pyvesync.device_container.DeviceContainer` class in the `manager.devices` attribute. The `DeviceContainer` class is a singleton class, so only one instance can exist. The class inherits from `MutableSet` so it contains unique objects, with the ability to add and remove devices using the `add`, `remove` and `clear` methods. However, these methods only accept device objects. To simplify removing devices, there is the `remove_by_cid(cid: str)` method. To get devices by device name, use the `get_by_name(name: str)` method. There are two convenience methods `add_new_devices` and `remove_stale_devices` that accept the device list response model. The `DeviceContainer` object has a property for each product type that returns a list of devices. For example, `DeviceContainer.outlets` returns a list of all outlets product type devices. ## Custom Exceptions Exceptions are no longer caught by the library and must be handled by the user. Exceptions are raised by server errors and aiohttp connection errors. Errors that occur at the aiohttp level are raised automatically and propogated to the user. That means exceptions raised by aiohttp that inherit from `aiohttp.ClientError` are propogated. When the connection to the VeSync API succeeds but returns an error code that prevents the library from functioning a custom exception inherrited from `pyvesync.logs.VeSyncError` is raised. Custom Exceptions raised by all API calls: - `pyvesync.logs.VeSyncServerError` - The API connected and returned a code indicated there is a server-side error. - `pyvesync.logs.VeSyncRateLimitError` - The API's rate limit has been exceeded. - `pyvesync.logs.VeSyncAPIStatusCodeError` - The API returned a non-200 status code. - `pyvesync.logs.VeSyncAPIResponseError` - The response from the API was not in an expected format. Login API Exceptions - `pyvesync.logs.VeSyncLoginError` - The username or password is incorrect. See [errors.py](./utils/errors.md) for a complete list of error codes and exceptions. ## VeSync API's The vesync API is typically one of two different types. The first is the Bypass V1 API, which is used for most devices. The second is the Bypass V2 API, which is used for newer devices. The `pyvesync.utils.device_mixins` module contains mixins for common device API's and helper methods. This allows for easy reuse of code and keeps the device modules clean(er). ### General Response Structure The general response structure for API calls is as follows: ```json { "traceId": "TIMESTAMP", "code": 0, "msg": "request success", "module": null, "stacktrace": null, } ``` The error code contains information from the API call on whether the request was successful or details on the error. The `code` value is parsed by the library and stored in `device.last_response` attribute. ### Bypass V1 The `BypassV1Mixin` class is used for generally older devices, such as bulbs, switches, outlets and the first air purifier model (LV-PUR131S). The API calls use the `post` method and the base endpoint path `/cloud/v1/deviceManaged/`. The final path segment can either be `bypass` or a specific function, such as `PowerCtl`. #### Bypass V1 Request Structure When the final path segment is not `bypass`, e.g. `/cloud/v1/deviceManaged/deviceDetail`, the method key of the API call is the same as the last path segment: ```json { "method": "deviceDetail", "acceptLanguage": "en_US", "appVersion": "1.0.0", "phoneBrand": "Android", "phoneOS": "Android 10", "accountID": "1234567890", "cid": deviceCID, "configModule": configModule, "debugMode": False, "traceId": 1234567890, "timeZone": "America/New_York", "token": "abcdefg1234567", "userCountryCode": "+1", "uuid": 1234567890, "configModel": configModule, "deviceId": deviceCID, } ``` There can also be additional keys in the body of the request, such as `"status": "on"`. There are not any nested dictionaries in the request body. For API calls that have the `bypass` path, the structure is slightly different. The value of the outer `method` key is `bypass` and the request contains the `jsonCmd` key, containing the details of the request: ```json { "method": "bypass", "acceptLanguage": "en_US", "appVersion": "1.0.0", "phoneBrand": "Android", "phoneOS": "Android 10", "accountID": "1234567890", "cid": deviceCID, "configModule": configModule, "debugMode": False, "traceId": 1234567890, "timeZone": "America/New_York", "token": "abcdefg1234567", "userCountryCode": "+1", "uuid": 1234567890, "configModel": configModule, "deviceId": deviceCID, "jsonCmd": { "getLightStatus": "get" } } ``` #### Bypass V1 Response Structure Responses for the Bypass V1 API calls have the following structure with the `result` value containing the response information: ```json { "traceId": "TIMESTAMP", "code": 0, "msg": "request success", "module": null, "stacktrace": null, "result": { "light": { "action": "off", "brightness": 30, "colorTempe": 5 } } } ``` #### Bypass V1 Device Mixin The `pyvesync.utils.device_mixins.BypassV1Mixin` class contains boilerplate code for the devices that use the Bypass V1 api. The mixin contains the `call_bypassv1_mixin` method that builds the request and calls the api. The method accepts the following parameters: ```python async def call_bypassv1_mixin( self, requestModel: type[RequestBypassV1], # Model for the request body update_dict: dict | None = None, # Allows additional keys to be provided in the request body method: str = "bypass", # Method value in request body endpoint: bool = False, # Last segment of API path ) -> tuple[dict[str, Any], int]: ... ``` The process_bypassv1_response method is used to parse the response, check for errors and return the value of the `result` key. The method accepts the following parameters: ```python def process_bypassv1_result( device: VeSyncBaseDevice, logger: Logger, method: str, resp_dict: dict | None, ) -> dict | None: ... ``` This is an example of the implementation: ```python from pyvesync.devices import VeSyncSwitch from pvyesync.models.switch_models import RequestSwitchDetails from pyvesync.utils.device_mixins import BypassV1Mixin, process_bypassv1_response class VSDevice(BypassV1Mixin, VeSyncSwitch): def get_details(self) -> bool: ... update_dict = { "jsonCmd": { "getStatus": "get" } } response = await self.call_bypassv1_api( requestModel=RequestSwitchDetails, update_dict=update_dict, method="PowerCtl", endpoint=True ) # The process_bypassv1_response method makes the appropriate logs if error in response result = process_bypassv1_response(self, logger, 'get_details', response) ``` **NOTE** The `process_bypassv1_response` method is not necessary for API calls that perform an action and return the simple response shown above with the `code` and `msg` keys and no `result` key. ### Bypass V2 The Bypass V2 API is used for newer devices, such as humidifiers. The API calls use the `post` method and the base endpoint path `/cloud/v2/deviceManaged/bypassV2`. The final path segment is always `bypassV2`. #### Bypass V2 Request Structure The bypass V2 request structure is very similiar between API calls. The outer `method` key always has the `bypassv2` attribute. The payload structure is always the same with the `method`, `data` and `source` keys. The `source` key always contains the value `APP`. The payload `method` and `data` keys change. ```json { "acceptLanguage": "en", "accountID": "ACCOUNTID", "appVersion": "VeSync 5.5.60", "cid": "deviceCID", "configModule": "configModule", "debugMode": false, "method": "bypassV2", "phoneBrand": "SM-A5070", "phoneOS": "Android 12", "timeZone": "America/New_York", "token": "TOKEN", "traceId": "1743902977493", "userCountryCode": "US", "deviceId": "deviceCID", "configModel": "configModule", "payload": { "data": {}, "method": "getPurifierStatus", "source": "APP" } } ``` #### Bypass V2 Response Structure The response structure has a relatively similiar structure for all calls with a nested result dictionary, containing an additional `code` and `device_error_code` key that provides information on errors that are specific to the device: ```json { "traceId": "TIMESTAMP", "code": 0, "msg": "request success", "module": null, "stacktrace": null, "result": { "traceId": "TIMESTAMP", "code": 0, "result": { "enabled": true, "filter_life": 98, "mode": "manual", "level": 4, "air_quality": 1, "air_quality_value": 2, "display": true, "child_lock": false, "configuration": { "display": true, "display_forever": true, "auto_preference": { "type": "efficient", "room_size": 1050 } }, "extension": { "schedule_count": 0, "timer_remain": 0 }, "device_error_code": 0 } } } ``` #### Bypass V2 Device Mixin The `pyvesync.utils.device_mixins.BypassV2Mixin` class contains boilerplate code for the devices that use the Bypass V1 api. The mixin contains the `call_bypassv1_mixin` method that builds the request and calls the api. The method accepts the following parameters: ```python async def call_bypassv2_api( self, payload_method: str, # Value of method in the payload key data: dict | None = None, # Dictionary to be passed in the payload data key method: str = "bypassV2", # Allows the outer method value to be changed endpoint: str = "bypassV2", # Allows the last segment of API path to be changed ) -> dict | None: ... ``` The process_bypassv2_response method is used to parse the response, check for errors and return the value of the inner `result` key. The method accepts the following parameters: ```python def process_bypassv2_results( device: VeSyncBaseDevice, logger: Logger, method: str, resp_dict: dict | None, ) -> dict | None: ``` This is an example of how it is used: ```python from pyvesync.base_devices import VeSyncPurifier from pyvesync.models.purifier_models import RequestPurifierDetails from pyvesync.utils.device_mixins import BypassV2Mixin, process_bypassv2_response class VSDevice(BypassV2Mixin, VeSyncPurifier): """VeSync Purifier device class.""" async def get_details(self) -> bool: """Get the details of the device.""" response = await self.call_bypassv2_api( payload_method="getPurifierStatus", data=update_dict ) # The process_bypassv2_response method makes the appropriate logs if error in response result = process_bypassv2_response(self, logger, 'get_details', response) ``` **NOTE** The `process_bypassv2_response` method is not necessary for API calls that perform an action and return the simple response shown above with the `code` and `msg` keys and no `result` key. webdjoe-pyvesync-eb8cecb/docs/development/testing.md000066400000000000000000000000001507433633000231070ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/docs/development/utils/000077500000000000000000000000001507433633000222625ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/docs/development/utils/colors.md000066400000000000000000000023351507433633000241100ustar00rootroot00000000000000# Color Handlers The `pyvesync.utils.colors` module provides classes and functions for handling color conversions and representations. It includes the `Color` class, which serves as a base for color manipulation, and the `HSV` and `RGB` classes for specific color models. The module is designed for internal use within the library and is not intended for public use. ## Color class This is the primary class that holds the color data and provides methods for conversion between different color models (RGB, HSV). It also includes methods for validating color values and generating color strings in various formats. ::: pyvesync.utils.colors.Color handler: python options: show_root_heading: true show_source: true filters: - "!Config" - "!^__init*" - "!__post_init__" - "!__str__" ::: pyvesync.utils.colors.HSV handler: python options: show_root_heading: true show_source: true filters: - "!Config" - "!^__init*" - "!__post_init__" - "!__str__" ::: pyvesync.utils.colors.RGB handler: python options: show_root_heading: true show_source: true filters: - "!Config" - "!__post_init__" - "!__str__" webdjoe-pyvesync-eb8cecb/docs/development/utils/device_mixins.md000066400000000000000000000025741507433633000254420ustar00rootroot00000000000000# Device Mixins The `pyvesync.utils.device_mixins` modules are used to add boilerplate code for the common API requests that are used by different devices. Please reference [Development](../index.md#vesync-apis) for more information on how to use the device mixins. ## Bypass V1 Mixins and functions ::: pyvesync.utils.device_mixins.BypassV1Mixin handler: python options: toc_label: "BypassV1Mixin Class" show_root_heading: true heading_level: 3 show_source: true filters: - "!.*pid" - "!^__*" ::: pyvesync.utils.device_mixins.process_bypassv1_result handler: python options: group_by_category: true show_category_heading: true heading_level: 3 show_root_heading: true show_source: true filters: - "!.*pid" - "!^__*" ## Bypass V2 Mixins and functions ::: pyvesync.utils.device_mixins.BypassV2Mixin handler: python options: toc_label: "BypassV2Mixin Class" show_root_heading: true heading_level: 3 show_source: true filters: - "!.*pid" - "!^__*" ::: pyvesync.utils.device_mixins.process_bypassv2_result handler: python options: group_by_category: true show_category_heading: true heading_level: 3 show_root_heading: true show_source: true filters: - "!.*pid" - "!^__*" webdjoe-pyvesync-eb8cecb/docs/development/utils/errors.md000066400000000000000000000061661507433633000241310ustar00rootroot00000000000000# Errors and Exceptions The `pyvesync` library parses the code provided in the API response to determine if the request was successful and a possible reason for failure. ::: pyvesync.utils.errors handler: python options: toc_label: "errors Module" show_root_heading: false show_root_toc_entry: false show_source: false members: false filters: - "!.*pid" - "!^__*" ::: pyvesync.utils.errors.ResponseInfo handler: python options: parameter_headings: true toc_label: "ResponseInfo Class" show_root_heading: true heading_level: 2 show_source: true filters: - "!.*pid" - "!^__*" ::: pyvesync.utils.errors.ErrorTypes handler: python options: parameter_headings: true toc_label: "ErrorTypes StrEnum" show_root_heading: true heading_level: 2 show_source: true filters: - "!.*pid" - "!^__*" ::: pyvesync.utils.errors.ErrorCodes handler: python options: parameter_headings: true toc_label: "ErrorCodes" show_root_heading: true heading_level: 2 show_source: true filters: - "!.*pid" - "!^__*" ::: pyvesync.utils.errors.raise_api_errors handler: python options: parameter_headings: true show_root_heading: true heading_level: 2 show_source: true filters: - "!.*pid" - "!^__*" ## VeSync Exceptions ::: pyvesync.utils.errors.VeSyncError handler: python options: parameter_headings: true toc_label: "VeSyncError" show_root_heading: true heading_level: 3 show_source: true filters: - "!.*pid" - "!^__*" ::: pyvesync.utils.errors.VeSyncLoginError handler: python options: parameter_headings: true show_root_heading: true heading_level: 3 show_source: true filters: - "!.*pid" - "!^__*" ::: pyvesync.utils.errors.VeSyncTokenError handler: python options: parameter_headings: true show_root_heading: true heading_level: 3 show_source: true filters: - "!.*pid" - "!^__*" ::: pyvesync.utils.errors.VeSyncServerError handler: python options: parameter_headings: true show_root_heading: true heading_level: 3 show_source: true filters: - "!.*pid" - "!^__*" ::: pyvesync.utils.errors.VeSyncRateLimitError handler: python options: parameter_headings: true show_root_heading: true heading_level: 3 show_source: true filters: - "!.*pid" - "!^__*" ::: pyvesync.utils.errors.VeSyncAPIResponseError handler: python options: parameter_headings: true show_root_heading: true heading_level: 3 show_source: true filters: - "!.*pid" - "!^__*" ::: pyvesync.utils.errors.VeSyncAPIStatusCodeError handler: python options: parameter_headings: true show_root_heading: true heading_level: 3 show_source: true filters: - "!.*pid" - "!^__*" webdjoe-pyvesync-eb8cecb/docs/development/utils/helpers.md000066400000000000000000000023661507433633000242550ustar00rootroot00000000000000# helpers module The `pyvesync.utils.helpers` module contains utility functions and classes that assist with various tasks within the library. These include converting values, processing states, and managing timers. The module is designed for internal use within the library and is not intended for public use. ## Timer class ::: pyvesync.utils.helpers.Timer handler: python options: members: - update show_root_heading: true show_source: true ## Helper class Contains common methods and attributes used by other pyvesync modules. ::: pyvesync.utils.helpers.Helpers handler: python options: show_root_heading: true show_category_heading: true show_root_toc_entry: true members_order: "source" inherited_members: false show_source: true filters: - "!Config" - "!^__.*" - "!^_.*" - "!req_body" ## Validators Class Contains common method to validate numerical values. ::: pyvesync.utils.helpers.Validators handler: python options: show_root_heading: true show_category_heading: true show_root_toc_entry: true inherited_members: false show_source: true filters: - "!Config" - "!^__.*" - "!^_.*" webdjoe-pyvesync-eb8cecb/docs/development/utils/logging.md000066400000000000000000000000001507433633000242200ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/docs/development/vesync_api.md000066400000000000000000000010651507433633000236060ustar00rootroot00000000000000# Documentation for `pyvesync.vesync` module This module instantiates the vesync instance that holds the devices and maintains authentication information. ::: pyvesync.vesync.VeSync handler: python options: group_by_category: true show_root_heading: true show_category_heading: true show_source: false filters: - "!.*_dev_test" - "!set_dev_id" - "!process_devices" - "!remove_old_devices" - "!device_time_check" merge_init_into_class: true show_signature_annotations: true webdjoe-pyvesync-eb8cecb/docs/development/vesync_device_base.md000066400000000000000000000032611507433633000252660ustar00rootroot00000000000000# Documentation for Base Devices This is the base device inherited by all device classes. This should *NOT* be instantiated directly. All methods and attributes are available on all devices. ::: pyvesync.base_devices.vesyncbasedevice.VeSyncBaseToggleDevice handler: python options: group_by_category: true show_category_heading: true show_root_full_path: false show_root_heading: true show_source: true merge_init_into_class: true show_if_no_docstring: true show_signature_annotations: true inherited_members: true docstring_options: ignore_init_summary: true filters: - "!.*pid" - "!^__*" - "!displayJSON" ::: pyvesync.base_devices.vesyncbasedevice.VeSyncBaseDevice handler: python options: group_by_category: true show_category_heading: true show_root_full_path: false show_root_heading: true show_source: true show_signature_annotations: true merge_init_into_class: true show_if_no_docstring: true show_signature_annotations: true docstring_options: ignore_init_summary: false filters: - "!.*pid" - "!^__*" - "!displayJSON" ::: pyvesync.base_devices.vesyncbasedevice.DeviceState handler: python options: group_by_category: true show_root_full_path: false show_category_heading: true show_root_heading: true show_source: true merge_init_into_class: true show_if_no_docstring: true show_signature_annotations: true docstring_options: ignore_init_summary: false filters: - "!.*pid" - "!^__*" webdjoe-pyvesync-eb8cecb/docs/devices/000077500000000000000000000000001507433633000202225ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/docs/devices/air_purifiers.md000066400000000000000000000072501507433633000234130ustar00rootroot00000000000000# VeSync Air Purifiers ## Contents: - [PurifierState Class][pyvesync.base_devices.purifier_base.PurifierState] - [VeSyncAirBypass Purifiers][pyvesync.devices.vesyncpurifier.VeSyncAirBypass] - Most common model - [VeSyncAirBaseV2 Purifiers][pyvesync.devices.vesyncpurifier.VeSyncAirBaseV2] - Newer models - [VeSyncAirSprout Purifiers][pyvesync.devices.vesyncpurifier.VeSyncAirSprout] - Sprout models - [VeSyncAir131 Purifiers][pyvesync.devices.vesyncpurifier.VeSyncAir131] - PUR-LV131S Purifiers - [VeSyncPurifier Base Device][pyvesync.base_devices.purifier_base.VeSyncPurifier] - Abstract base class ::: pyvesync.base_devices.purifier_base.PurifierState options: filters: - "!^_.*" summary: functions: false group_by_category: true toc_label: "PurifierState" show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: false ::: pyvesync.devices.vesyncpurifier.VeSyncAirBypass options: filters: - "!^_.*" summary: functions: false group_by_category: true toc_label: "VeSyncAirBypass Purifiers" show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: false ::: pyvesync.devices.vesyncpurifier.VeSyncAirBaseV2 options: filters: - "!^_.*" summary: functions: false group_by_category: true toc_label: "VeSyncAirBaseV2 Purifiers" show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: false ::: pyvesync.devices.vesyncpurifier.VeSyncAirSprout options: filters: - "!^_.*" summary: functions: false group_by_category: true toc_label: "VeSyncAirSprout Purifiers" show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: false ::: pyvesync.devices.vesyncpurifier.VeSyncAir131 options: filters: - "!^_.*" summary: functions: false group_by_category: true toc_label: "VeSyncAir131 Purifiers" show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: false ::: pyvesync.base_devices.purifier_base.VeSyncPurifier options: filters: - "!^_.*" summary: functions: false group_by_category: true toc_label: "VeSyncPurifier Base Device" show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: false webdjoe-pyvesync-eb8cecb/docs/devices/bulbs.md000066400000000000000000000104031507433633000216510ustar00rootroot00000000000000# Documentation for Etekcity / Valceno Smart Bulbs ## Table of Contents See each device class for available attributes and methods: - [BulbState Class](#pyvesync.base_devices.bulb_base.BulbState) - [Etekcity Smart Bulb ESL100](#pyvesync.devices.vesyncbulb.VeSyncBulbESL100) - [Etekcity Smart Bulb ESL100CW](#pyvesync.devices.vesyncbulb.VeSyncBulbESL100CW) - [Etekcity Smart Bulb ESL100MC](#pyvesync.devices.vesyncbulb.VeSyncBulbESL100MC) - [Valceno Smart Bulb ESL100MC](#pyvesync.devices.vesyncbulb.VeSyncBulbValcenoA19MC) - [VeSyncBulb Abstract Base Class](#pyvesync.base_devices.bulb_base.VeSyncBulb) ::: pyvesync.base_devices.bulb_base.BulbState options: toc_label: "BulbState" filters: - "!^_.*" summary: functions: false group_by_category: true show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: true ::: pyvesync.devices.vesyncbulb.VeSyncBulbESL100 options: filters: - "!^_.*" - "__init__" - "!color*" - "!rgb*" - "!hsv*" - "!get_pid" summary: functions: false group_by_category: true show_root_heading: true toc_label: "Etekcity Dimmable ESL100" show_root_toc_entry: true show_category_heading: true show_source: true show_signature_annotations: true signature_crossrefs: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: true ::: pyvesync.devices.vesyncbulb.VeSyncBulbESL100CW options: filters: - "!^_.*" - "__init__" - "!color*" - "!rgb*" - "!hsv*" - "!get_pid" summary: functions: false group_by_category: true show_root_heading: true toc_label: "Etekcity Tunable ESL100CW" show_root_toc_entry: true show_category_heading: true show_source: true show_signature_annotations: true show_if_no_docstring: true signature_crossrefs: true inherited_members: true docstring_options: ignore_init_summary: false merge_init_into_class: true ::: pyvesync.devices.vesyncbulb.VeSyncBulbESL100MC options: filters: - "!^_.*" - "__init__" - "!get_pid" summary: functions: false group_by_category: true toc_label: "Etekcity Multicolor ESL100MC" show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_signature_annotations: true show_if_no_docstring: true signature_crossrefs: true inherited_members: true docstring_options: ignore_init_summary: false merge_init_into_class: true ::: pyvesync.devices.vesyncbulb.VeSyncBulbValcenoA19MC options: filters: - "!^_.*" - "__init__" - "!get_pid" summary: functions: false group_by_category: true toc_label: "Valceno Multicolor Bulb" show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: false merge_init_into_class: true ::: pyvesync.base_devices.bulb_base.VeSyncBulb options: filters: - "!^_.*" summary: functions: false group_by_category: true show_root_heading: true show_root_toc_entry: true toc_label: "VeSyncBulb Base Class" show_category_heading: true show_source: true heading_level: 3 inherited_members: true show_if_no_docstring: true docstring_options: ignore_init_summary: true merge_init_into_class: true webdjoe-pyvesync-eb8cecb/docs/devices/fans.md000066400000000000000000000031731507433633000214770ustar00rootroot00000000000000# VeSync Fans (NOT Purifiers or Humidifiers) ::: pyvesync.base_devices.fan_base.FanState options: filters: - "!^_.*" - "!__init__" summary: functions: false group_by_category: true toc_label: "FanState" show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: false ::: pyvesync.devices.vesyncfan.VeSyncTowerFan options: filters: - "!^_.*" - "!__init__" summary: functions: false group_by_category: true show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: false show_signature_annotations: true signature_crossrefs: true inherited_members: true docstring_options: ignore_init_summary: false merge_init_into_class: true ::: pyvesync.base_devices.fan_base.VeSyncFanBase options: filters: - "!^_.*" - "!__init__" summary: functions: false group_by_category: true toc_label: "VeSyncFan Base Class" show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: false webdjoe-pyvesync-eb8cecb/docs/devices/humidifiers.md000066400000000000000000000073311507433633000230600ustar00rootroot00000000000000# VeSync Humidifiers ## Contents - [HumidifierState Class][pyvesync.base_devices.humidifier_base.HumidifierState] - [VeSync 300S Humidifiers][pyvesync.devices.vesynchumidifier.VeSyncHumid200300S] - Includes OasisMist 4.5L devices - [VeSync 200S Humidifiers][pyvesync.devices.vesynchumidifier.VeSyncHumid200S] - Same as 300S class with override for toggle display method - [VeSync Superior 6000S Humidifiers][pyvesync.devices.vesynchumidifier.VeSyncSuperior6000S] - [VeSync OasisMist 1000S Humidifiers][pyvesync.devices.vesynchumidifier.VeSyncHumid1000S] - [VeSync Humidifier Base Class][pyvesync.base_devices.humidifier_base.VeSyncHumidifier] - Abstract base class ::: pyvesync.base_devices.humidifier_base.HumidifierState options: filters: - "!^_.*" summary: functions: false group_by_category: true toc_label: "HumidiferState" show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: false ::: pyvesync.devices.vesynchumidifier.VeSyncHumid200300S options: toc_label: "VeSync 200S/300S" filters: - "!^_.*" summary: functions: false group_by_category: true show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: true ::: pyvesync.devices.vesynchumidifier.VeSyncHumid200S options: toc_label: "VeSync 200S" filters: - "!^_.*" summary: functions: false group_by_category: true show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: true ::: pyvesync.devices.vesynchumidifier.VeSyncSuperior6000S options: toc_label: "VeSync Superior 6000S" filters: - "!^_.*" summary: functions: false group_by_category: true show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: true ::: pyvesync.devices.vesynchumidifier.VeSyncHumid1000S options: toc_label: "VeSync OasisMist 1000S" filters: - "!^_.*" summary: functions: false group_by_category: true show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: true ::: pyvesync.base_devices.humidifier_base.VeSyncHumidifier options: filters: - "!^_.*" summary: functions: false group_by_category: true toc_label: "HumidiferState" show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: falsewebdjoe-pyvesync-eb8cecb/docs/devices/index.md000066400000000000000000000007711507433633000216600ustar00rootroot00000000000000# Device Classes and States Devices are organized into modules by product type. Each page shows the methods and state attributes of each device class. 1. [Bulbs](./bulbs.md) 2. [Fans](fans.md) 3. [Humidifiers](humidifiers.md) 4. [Outlets](outlets.md) 5. [Purifiers](air_purifiers.md) 6. [Switches](switches.md) 7. [Thermostats](thermostats.md) 8. [Air Fryers](kitchen.md) For a list of supported devices and their corresponding device classes, see the [Supported Devices](../supported_devices.md) page.webdjoe-pyvesync-eb8cecb/docs/devices/kitchen.md000066400000000000000000000017011507433633000221700ustar00rootroot00000000000000# VeSync Air Fryers Currently the only supported air fryer is the Cosori 3.7 and 5.8 Quart Air Fryer. This device is a smart air fryer that can be monitored and controlled via this library. ::: pyvesync.devices.vesynckitchen options: show_root_heading: true members: false ::: pyvesync.devices.vesynckitchen.AirFryer158138State options: show_root_heading: true members_order: source filters: - "!^_.*" ::: pyvesync.devices.vesynckitchen.VeSyncAirFryer158 options: show_root_heading: true members_order: source filters: - "!^_.*" ::: pyvesync.base_devices.fryer_base.FryerState options: show_root_heading: true members_order: source filters: - "!^_.*" ::: pyvesync.base_devices.fryer_base.VeSyncFryer options: show_root_heading: true members_order: source filters: - "!^_.*" webdjoe-pyvesync-eb8cecb/docs/devices/outlets.md000066400000000000000000000113441507433633000222460ustar00rootroot00000000000000# VeSync Outlets ## Overview - [Outlet State Object][pyvesync.base_devices.outlet_base.OutletState] - [Etekcity 7A Outlet][pyvesync.devices.vesyncoutlet.VeSyncOutlet7A] - [Etekcity 10A European Outlet][pyvesync.devices.vesyncoutlet.VeSyncOutlet10A] - [Etekcity 15A Outlet][pyvesync.devices.vesyncoutlet.VeSyncOutlet15A] - [Etekcity 15A Outdoor Dual Outlet][pyvesync.devices.vesyncoutlet.VeSyncOutdoorPlug] - [Etekcity 10A USA Outlet][pyvesync.devices.vesyncoutlet.VeSyncESW10USA] - [VeSync BSODG Smart Outlet][pyvesync.devices.vesyncoutlet.VeSyncOutletBSDGO1] - [VeSyncOutlet Base Class][pyvesync.base_devices.outlet_base.VeSyncOutlet] ::: pyvesync.base_devices.outlet_base.OutletState options: filters: - "!^_.*" summary: functions: false group_by_category: true toc_label: "OutletState" show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: false ::: pyvesync.devices.vesyncoutlet.VeSyncOutlet7A options: filters: - "!^_.*" summary: functions: false group_by_category: true toc_label: "VeSync 7A Outlet" show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: false ::: pyvesync.devices.vesyncoutlet.VeSyncOutlet10A options: filters: - "!^_.*" summary: functions: false group_by_category: true toc_label: "VeSyncOutlet 10A Round" show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: false ::: pyvesync.devices.vesyncoutlet.VeSyncOutlet15A options: filters: - "!^_.*" summary: functions: false group_by_category: true toc_label: "VeSyncOutlet 15A Rectangle" show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: false ::: pyvesync.devices.vesyncoutlet.VeSyncOutdoorPlug options: filters: - "!^_.*" summary: functions: false group_by_category: true toc_label: "VeSync Outdoor Outlet" show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: false ::: pyvesync.devices.vesyncoutlet.VeSyncESW10USA options: filters: - "!^_.*" summary: functions: false group_by_category: true toc_label: "VeSyncOutlet BSDGO1" show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: false ::: pyvesync.devices.vesyncoutlet.VeSyncOutletBSDGO1 options: filters: - "!^_.*" summary: functions: false group_by_category: true toc_label: "VeSyncOutlet BSDGO1" show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: false ::: pyvesync.base_devices.outlet_base.VeSyncOutlet options: filters: - "!^_.*" summary: functions: false group_by_category: true toc_label: "VeSyncOutlet Base Class" show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: false webdjoe-pyvesync-eb8cecb/docs/devices/switches.md000066400000000000000000000050361507433633000224010ustar00rootroot00000000000000# Documentation for Etekcity Smart Switches ## Contents See each device class for available attributes and methods: - [SwitchState Class](#pyvesync.base_devices.switch_base.SwitchState) - [Etekcity Dimmer Switch EWD16](#pyvesync.devices.vesyncswitch.VeSyncDimmerSwitch) - [Etekcity Wall Switch ESWL01 & ESWL03](#pyvesync.devices.vesyncswitch.VeSyncWallSwitch) - [VeSyncSwitch Abstract Base Class](#pyvesync.base_devices.switch_base.VeSyncSwitch) ::: pyvesync.base_devices.switch_base.SwitchState options: toc_label: "SwitchState" filters: - "!^_.*" summary: functions: false group_by_category: true show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: true ::: pyvesync.devices.vesyncswitch.VeSyncWallSwitch options: toc_label: "VeSyncWall & 3-Way Switch" filters: - "!^_.*" summary: functions: false group_by_category: true show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: true ::: pyvesync.devices.vesyncswitch.VeSyncDimmerSwitch options: toc_label: "Etekcity Dimmer Switch" filters: - "!^_.*" summary: functions: false group_by_category: true show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: true ::: pyvesync.base_devices.switch_base.VeSyncSwitch options: toc_label: "VeSyncSwitch Base Device" filters: - "!^_.*" summary: functions: false group_by_category: true show_root_heading: true show_root_toc_entry: true show_category_heading: true show_source: true show_if_no_docstring: true inherited_members: true docstring_options: ignore_init_summary: true merge_init_into_class: true webdjoe-pyvesync-eb8cecb/docs/devices/thermostats.md000066400000000000000000000007711507433633000231260ustar00rootroot00000000000000# VeSync Thermostats ::: pyvesync.devices.vesyncthermostat options: show_root_heading: true members: false members_order: source ::: pyvesync.base_devices.thermostat_base.ThermostatState options: show_root_heading: true members_order: source filters: - "!^_.*" ::: pyvesync.base_devices.thermostat_base.VeSyncThermostat options: show_root_heading: true members_order: source filters: - "!^_.*"webdjoe-pyvesync-eb8cecb/docs/hooks.py000066400000000000000000000022221507433633000202730ustar00rootroot00000000000000"""Utility hooks for mkDocs.""" import re import urllib.request def replacer(match: re.Match) -> str: """Find and replace github links with code snippets. This function parses a Github link and retreives the specified lines of code from the file, formatting it as a markdown code block. """ filename = f'{match.group(3)}.{match.group(4)}' url = ( f'https://raw.githubusercontent.com/{match.group(1)}/{match.group(2)}/{filename}' ) code = urllib.request.urlopen(url).read().decode('utf-8') # noqa: S310 extension = 'js' if match.group(4) == 'vue' else match.group(4) return '\n'.join( [f'``` {extension} title="{filename}"'] + code.split('\n')[int(match.group(5)) - 1: int(match.group(6))] + ['```', f'View at [GitHub]({match.group(0)})'] ) def on_page_markdown(markdown: str, **kwargs) -> str: # noqa: ARG001, ANN003 """Replace GitHub links with code snippets.""" return re.sub( re.compile( r'^https://github.com/([\w/\-]+)/blob/([0-9a-f]+)/([\w\d\-/\.]+)\.(\w+)#L(\d+)-L(\d+)$', re.MULTILINE, ), replacer, markdown, ) webdjoe-pyvesync-eb8cecb/docs/index.md000066400000000000000000000377001507433633000202400ustar00rootroot00000000000000# pyvesync Library [![build status](https://img.shields.io/pypi/v/pyvesync.svg)](https://pypi.python.org/pypi/pyvesync) [![Build Status](https://dev.azure.com/webdjoe/pyvesync/_apis/build/status/webdjoe.pyvesync?branchName=master)](https://dev.azure.com/webdjoe/pyvesync/_build/latest?definitionId=4&branchName=master) [![Open Source? Yes!](https://badgen.net/badge/Open%20Source%20%3F/Yes%21/blue?icon=github)](https://github.com/Naereen/badges/) [![PyPI license](https://img.shields.io/pypi/l/ansicolortags.svg)](https://pypi.python.org/pypi/ansicolortags/) **This is a work in progress, PR's greatly appreciated 🙂.** pyvesync is a python library that interacts with devices that are connected to the VeSync app. The library can pull state details and perform actions to devices once they are set up in the app. This is not a local connection, the pyvesync library connects the VeSync cloud API, which reads and sends commands to the device, so internet access is required for both the device and library. There is no current method to control locally. ## Supported devices The following product types are supported: 1. `outlet` - Outlet devices 2. `switch` - Wall switches 3. `fan` - Fans (not air purifiers or humidifiers) 4. `purifier` - Air purifiers (not humidifiers) 5. `humidifier` - Humidifiers (not air purifiers) 6. `bulb` - Light bulbs (not dimmers or switches) 7. `airfryer` - Air fryers See [Supported Devices](supported_devices.md) for a complete list of supported devices and models. ## What's New **BREAKING CHANGES** - The release of pyvesync 3.0 comes with many improvements and new features, but as a result there are many breaking changes. The structure has been completely refactored, so please read through the documentation and thoroughly test before deploying. The goal is to standardize the library across all devices to allow easier and consistent maintainability moving forward. The original library was created 8 years ago for supporting only a few outlets, it was not designed for supporting 20+ different devices. This will **DEFINITELY** cause breaking changes with existing code, but the new structure should be easier to work with and more consistent across all devices in the future. Some of the major structural changes include: - Asynchronous API calls through aiohttp - [`DeviceContainer`][pyvesync.device_container.DeviceContainer] object holds all devices in a mutable set structure with additional convenience methods and properties for managing devices. This is located in the `VeSync.manager` attribute. - Custom exceptions for better error handling there are custom exceptions that inherit from [`VeSyncError`][pyvesync.utils.errors.VeSyncError], `VeSyncAPIError`, `VeSyncLoginError`, `VeSyncRateLimitError`. - Device state has been separated from the device object and is now managed by the device specific subclasses of [`DeviceState`][pyvesync.base_devices.vesyncbasedevice.DeviceState]. The state object is located in the `state` attribute of the device object. - Device Classes have been refactored to be more consistent and easier to manage. No more random property and method names for different types of the same device. - [`const`][pyvesync.const] module to hold all library constants. - [`device_map`][pyvesync.device_map] module holds all device type mappings and configuration. - Authentication logic has been moved to the [`auth`][pyvesync.auth] module and is now handled by the `VeSync.auth` attribute. This can store and load credentials, so login is not required every time. See [pyvesync V3](./pyvesync3.md) for more details. ## Questions or Help? If you have a bug or enhancement request for the pyvesync library, please submit an [issue](https://github.com/webdjoe/pyvesync/issues). Looking to submit a request for a new device or have a general functionality question, check out [discussions](https://github.com/webdjoe/pyvesync/discussions). For home assistant issues, please submit on that repository. ### New Devices If you would like to add a new device, packet captures must be provided. The iOS and Android VeSync app implements certificate pinning to prevent standard MITM intercepts. Currently, the only known way is to patch the APK and run on a rooted android emulator with frida. This is a complex process that has had varying levels of success. If you successful in capturing packets, please capture all functionality, including the device list and configuraiton screen. Redact accountId and token keys or contact me for a more secure way to share. ## General Usage The [`pyvesync.vesync.VeSync`][pyvesync.vesync.VeSync] object is the primary class that is referred to as the `manager` because it provides all the methods and properties to control the library. This should be the only class that is directly instantiated. All devices will be instantiated and managed by the `VeSync` object. See the [Usage](./usage.md) documentation for a quick start guide on how to use the library. ### VeSync Manager The `VeSync` class has the following parameters, `username` and `password` are mandatory: ```python from pyvesync import VeSync # debug and redact are optional arguments, the above values are # the defaults. The library will try to automatically pull in # the correct time zone from the API responses. async with VeSync( username="user", password="password", country_code="US", # Optional - country Code to select correct server session=session, # Optional - aiohttp.ClientSession time_zone="America/New_York", # Optional - Timezone, defaults to America/New_York debug=False, # Optional - Debug output redact=True # Optional - Redact sensitive information from logs ) as manager: # VeSync object is now instantiated await manager.login() # Check if logged in assert manager.enabled # Debug and Redact are optional arguments manager.debug = True manager.redact = True # Get devices await manager.get_devices() # Device objects are now instantiated, but do not have state await manager.update() # Pulls in state and updates all devices # Or iterate through devices and update individually for device in manager.outlets: await device.update() # Or update a product type of devices: for outlet in manager.devices.outlets: await outlet.update() ``` If you want to reuse your token and account_id between runs. The `VeSync.auth` object holds the credentials and helper methods to save and load credentials. ```python import asyncio from pyvesync import VeSync from pyvesync.logs import VeSyncLoginError # VeSync is an asynchronous context manager # VeSync(username, password, debug=False, redact=True, session=None) async def main(): async with VeSync("user", "password") as manager: # If credentials are stored in a file, it can be loaded # the default location is ~/.vesync_token await manager.load_credentials_from_file() # or the file path can be passed await manager.load_credentials_from_file("/path/to/token_file") # Or credentials can be passed directly manager.set_credentials("your_token", "your_account_id") # No login needed # await manager.login() # To store credentials to a file after login await manager.save_credentials() # Saves to default location ~/.vesync_token # or pass a file path await manager.save_credentials("/path/to/token_file") # Output Credentials as JSON String await manager.output_credentials() await manager.update() # Acts as a set of device instances device_container = manager.devices outlets = device_container.outlets # List of outlet instances outlet = outlets[0] await outlet.update() await outlet.turn_off() outlet.display() # Iterate of entire device list for devices in device_container: device.display() if __name__ == "__main__": asyncio.run(main()) ``` Once logged in, the next call should be to the `update()` method which: - Retrieves list of devices from server - Removes stale devices that are not present in the new API call - Adds new devices to the device list attributes - Instantiates all device classes - Updates all devices via the device instance `update()` method ```python # Get/Update Devices from server - populate device lists manager.update() # Device instances are now located in the devices attribute: devices = manager.devices # Mutable set-like class # The entire container can be iterated over: for device in manager.devices: print(device) # Or iterate over specific device types: for outlet in manager.outlets: print(outlet) ``` Updating all devices without pulling a device list: ```python manager.update_all_devices() ``` Each product type is a property in the `devices` attribute: ```python manager.devices.outlets = [VeSyncOutletInstances] manager.devices.switches = [VeSyncSwitchInstances] manager.devices.fans = [VeSyncFanInstances] manager.devices.bulbs = [VeSyncBulbInstances] manager.devices.purifiers = [VeSyncPurifierInstances] manager.devices.humidifiers = [VeSyncHumidifierInstances] manager.devices.air_fryers = [VeSyncAirFryerInstances] managers.devices.thermostats = [VeSyncThermostatInstances] ``` ### Debugging and Getting Help The library has built-in debug logging that can be enabled by setting the `debug` attribute of the `VeSync` object to `True`. This will log the API calls and response codes to the console. This will not log the actual API calls. ```python from pyvesync import VeSync async with VeSync(username="EMAIL", password="PASSWORD") as manager: manager.debug = True # Enable debug logging await manager.login() await manager.get_devices() ``` To log the full API request and response, set the `verbose` attribute to `True`. This will log the full request and response, including headers and body. This is useful for debugging issues with the API calls. Setting the `redact` attribute to `False` will not redact the sensitive information in the logs, such as the username and password. `redact` is `True` by default. ```python from pyvesync import VeSync async with VeSync(username="EMAIL", password="PASSWORD") as manager: manager.debug = True # Enable debug logging manager.verbose = True # Enable verbose logging await manager.login() await manager.get_devices() ``` Since verbose debugging can output a lot of information, there is a helper function to log to a file instead of the console. ```python from pyvesync import VeSync async with VeSync(username="EMAIL", password="PASSWORD") as manager: manager.debug = True # Enable debug logging manager.verbose = True # Enable verbose logging await manager.log_to_file("debug.log") # Can be an absolute or path relative to the current working directory await manager.login() await manager.get_devices() # Log to a file instead of the console ``` ### Device Usage Devices and their attributes and methods can be accessed via the device lists in the manager instance. One device is simple to access, an outlet for example: ```python for devices in manager.outlets: print(outlet) print(outlet.device_name) print(outlet.device_type) print(outlet.sub_device_no) print(outlet.state) ``` The last response information is stored in the `last_response` attribute of the device and returns the [`ResponseInfo`][pyvesync.utils.errors.ResponseInfo] object. ```python # Get the last response information print(outlet.last_response) ResponseInfo( error_name="SUCCESS", error_type=SUCCESS, # StrEnum from ErrorTypes class message="Success", critical_error=False, operational_error=False, device_online=True, ) ``` ### Device State All device state information is stored in a separate object in the `device.state` attribute. Each device has it's own state class that inherits from the `pyvesync.base_devices.vesyncbasedevice.DeviceState` class. The state object is updated when the device is updated, so it will always be the most recent state of the device. Each product type has it's own state class that hold all available attribute for every device type supported for that product type. For example, purifiers have the `PurifierState` class, which holds all attributes, some of which may not be supported by all devices. ```python print(outlet.state.device_status) DeviceStatus.ON # StrEnum representing the "on" state print(outlet.state.connection_status) ConnectionStatus.ONLINE # StrEnum representing the "online" state print(outlet.state.voltage) 120.0 # Voltage of the device ``` For a full listing of available device attributes and methods, see the individual device documentation: 1. [Outlets](./devices/outlets.md) 2. [Bulbs](./devices/bulbs.md) 3. [Switches](./devices/switches.md) 4. [Purifiers](./devices/air_purifiers.md) 5. [Humidifiers](./devices/humidifiers.md) 6. [Fans](./devices/fans.md) 7. [Air Fryers](./devices/kitchen.md) 8. [Thermostats](./devices/thermostats.md) ## Serializing/Deserializing devices and state All devices have the `to_json()` and `to_jsonb()` methods which will serialize the device and state to a JSON string or binary json string. ```python device = manager.outlets[0] print(device.to_json(state=True, indent=True)) # JSON string # Setting `state=False` will only serialize the attributes in the device class and not the state class print(device.to_jsonb(state=True)) # Binary JSON string # State classes also have the `to_json()` and `to_jsonb()` methods but this does not include any device information, such as device_name, device_type, etc. print(device.state.to_json(indent=True)) # JSON string ``` Devices and state objects can also output to a dictionary using the `to_dict()` method or as a list of tuples with `as_tuple()`. This is useful for logging or debugging. ```python device = manager.outlets[0] dev_dict = device.to_dict(state=True) # Dictionary of device and state attributes dev_dict["device_name"] # Get the device name from the dictionary dev_dict["device_status"] # Returns device status as a StrEnum ``` ## Custom Exceptions Exceptions are no longer caught by the library and must be handled by the user. Exceptions are raised by server errors and aiohttp connection errors. Errors that occur at the aiohttp level are raised automatically and propogated to the user. That means exceptions raised by aiohttp that inherit from `aiohttp.ClientError` are propogated. When the connection to the VeSync API succeeds but returns an error code that prevents the library from functioning a custom exception inherrited from `pyvesync.logs.VeSyncError` is raised. Custom Exceptions raised by all API calls: - `pyvesync.logs.VeSyncServerError` - The API connected and returned a code indicated there is a server-side error. - `pyvesync.logs.VeSyncRateLimitError` - The API's rate limit has been exceeded. - `pyvesync.logs.VeSyncAPIStatusCodeError` - The API returned a non-200 status code. - `pyvesync.logs.VeSyncAPIResponseError` - The response from the API was not in an expected format. Login API Exceptions - `pyvesync.logs.VeSyncLoginError` - The username or password is incorrect. See [errors](https://webdjoe.github.io/pyvesync/latest/development/utils/errors) documentation for a complete list of error codes and exceptions. The [raise_api_errors()](https://webdjoe.github.io/pyvesync/latest/development/utils/errors/#pyvesync.utils.errors.raise_api_errors) function is called for every API call and checks for general response errors. It can raise the following exceptions: - `VeSyncServerError` - The API connected and returned a code indicated there is a server-side error. - `VeSyncRateLimitError` - The API's rate limit has been exceeded. - `VeSyncAPIStatusCodeError` - The API returned a non-200 status code. - `VeSyncTokenError` - The API returned a token error and requires `login()` to be called again. - `VeSyncLoginError` - The user name or password is incorrect. ## Development For details on the structure and architecture of the library, see the [Development](./development/index.md) documentation. webdjoe-pyvesync-eb8cecb/docs/pyvesync3.md000066400000000000000000000443371507433633000211000ustar00rootroot00000000000000# pyvesync V3.0 Changes **BREAKING CHANGES** - The release of pyvesync 3.0 comes with many improvements and new features, but as a result there are many breaking changes. The structure has been completely refactored, so please read through the documentation and thoroughly test before deploying. The goal is to standardize the library across all devices to allow easier and consistent maintainability moving forward. The original library was created 8 years ago for supporting only a few outlets, it was not designed for supporting 20+ different devices. Some of the changes are: - Asynchronous network requests with aiohttp - Strong typing of all network requests and responses. - New `product_type` nomenclature to map with API. - Base classes for all product types for easier `isinstance` checks. - Standardized the API for all device to follow a common naming convention. - Custom exceptions and error (code) handling for API responses. - `last_response` attribute on device instances to hold information on the last API response. - [`DeviceContainer`][pyvesync.device_container] object holds all devices in a mutable set structure with additional convenience methods and properties for managing devices. This is located in the `VeSync.manager.devices` attribute. - Custom exceptions for better error handling - [`VeSyncError`][pyvesync.utils.errors.VeSyncError], `VeSyncAPIException`, `VeSyncLoginException`, `VeSyncRateLimitException`, `VeSyncNoDevicesException` - Device state has been separated from the device object and is now managed by the device specific subclasses of [`DeviceState`][pyvesync.base_devices.vesyncbasedevice.DeviceState]. The state object is located in the `state` attribute of the device object. - [`const`][pyvesync.const] module to hold all library constants. - [`device_map`][pyvesync.device_map] module holds all device type mappings and configuration. - Allow for the changing of regions and API base URL. If you submit a PR please ensure that it follows all conventions outlined in [CONTRIBUTING](./development/contributing.md). ## Asynchronous operation Library is now asynchronous, using aiohttp as a replacement for requests. The `pyvesync.VeSync` class is an asynchronous context manager. A `aiohttp.ClientSession` can be passed or created internally. ```python import asyncio import aiohttp from pyvesync.vesync import VeSync async def main(): async with VeSync("user", "password", country_code="US") as manager: await manager.login() # Still returns true if not manager.enabled: print("Not logged in.") return await manager.get_devices() # Instantiates supported devices in device list await manager.update() # Updates the state of all devices for outlet in manager.devices.outlets: # The outlet object contain all action methods and static device attributes await outlet.update() await outlet.turn_off() outlet.display() # Print static device information, name, type, CID, etc. # State of object held in `device.state` attribute print(outlet.state) state_json = outlet.dumps() # Returns JSON string of device state state_bytes = orjson.dumps(outlet.state) # Returns bytes of device state # to view the response information of the last API call print(outlet.last_response) # Prints a ResponseInfo object containing error code, # and other response information # Or use your own session session = aiohttp.ClientSession() async def main(): async with VeSync("user", "password", session=session): await manager.login() await manager.update() if __name__ == "__main__": asyncio.run(main()) ``` If using `async with` is not ideal, the `__aenter__()` and `__aexit__()` methods need to be called manually: ```python manager = VeSync(user, password) await manager.__aenter__() ... await manager.__aexit__(None, None, None) ``` pvesync will close the `ClientSession` that was created by the library on `__aexit__`. If a session is passed in as an argument, the library does not close it. If a session is passed in and not closed, aiohttp will generate an error on exit: ```text 2025-02-16 14:41:07 - ERROR - asyncio - Unclosed client session 2025-02-16 14:41:07 - ERROR - asyncio - Unclosed connector ``` ## VeSync Class Signature The VeSync signature is: ```python VeSync( username: str, password: str, session: ClientSession | None = None, time_zone: str = DEFAULT_TZ # America/New_York ) ``` The VeSync class no longer accepts a `debug` or `redact` argument. To set debug the library set `manager.debug = True` to the instance and `manager.redact = True`. ## Product Types There is a new nomenclature for product types that defines the device class. The `device.product_type` attribute defines the product type based on the VeSync API. The product type is used to determine the device class and module. The currently supported product types are: 1. `outlet` - Outlet devices 2. `switch` - Wall switches 3. `fan` - Fans (not air purifiers or humidifiers) 4. `purifier` - Air purifiers (not humidifiers) 5. `humidifier` - Humidifiers (not air purifiers) 6. `bulb` - Light bulbs (not dimmers or switches) 7. `airfryer` - Air fryers See [Supported Devices](./supported_devices.md) for a complete list of supported devices and models. ## Custom Exceptions Exceptions are no longer caught by the library and must be handled by the user. Exceptions are raised by server errors and aiohttp connection errors. Errors that occur at the aiohttp level are raised automatically and propogated to the user. That means exceptions raised by aiohttp that inherit from `aiohttp.ClientError` are propogated. When the connection to the VeSync API succeeds but returns an error code that prevents the library from functioning a custom exception inherrited from `pyvesync.utils.errors.VeSyncError` is raised. Custom Exceptions raised by all API calls: - [VeSyncServerError][pyvesync.utils.errors.VeSyncServerError] - The API connected and returned a code indicated there is a server-side error. - [VeSyncRateLimitError][pyvesync.utils.errors.VeSyncRateLimitError] - The API's rate limit has been exceeded. - [VeSyncAPIStatusCodeError][pyvesync.utils.errors.VeSyncAPIStatusCodeError] - The API returned a non-200 status code. - [VeSyncAPIResponseError][pyvesync.utils.errors.VeSyncAPIResponseError] - The response from the API was not in an expected format. Login API Exceptions - [VeSyncLoginError][pyvesync.utils.errors.VeSyncLoginError] - The username or password is incorrect. See [errors](./development/utils/errors.md) documentation for a complete list of error codes and exceptions. ## Device Last Response Information If no exception is raised by the API, the response code and message of the last API call is stored in the `device.last_response` attribute. This is a [`ResponseInfo`][pyvesync.utils.errors.ResponseInfo] object that contains the following attributes: ```python ResponseInfo( name="SUCCESS", error_type="", message="", critical_error=False, operational_error=False, device_online=True ) ``` The ResponseInfo object is populated from the API response and source code. ## Input Validation When values that are required to be in a range are input, such as RGB/HSV colors, fan levels, etc. the library will no longer automatically adjust values outside of that range. The function performing the operation will just return False, with a debug message in the log. This is to minimize complexity and utility of the underlying code. If an invalid input is provided, the library should not assume to correct it. For example, setting bulb RGB color: **OLD OPERATION** - Entering values outside the accepted range were corrected to the nearest extreme and the operation is performed. ```python set_rgb = await bulb.set_rgb(300,0,-50) assert set_rgb == True # Bulb was set to the min/max RGB 255,0,0 ``` **NEW OPERATION** - invalid values return false and operation is not performed. ```python set_rgb = await bulb.set_rgb(300,0,-50) assert set_rgb == False ``` All methods that set RGB/HSV color now require all three inputs, red/green/blue or hue/saturation/value. I do not see the use in updating `red`, `green` or `blue`/`hue`, `saturation` or `value` individually. If you have a strong need for this, please open an issue with a detailed use case. ## Strong Typing with Mashumaro All API requests and responses must be deserialized with a mashumaro dataclass. The dataclass is used to validate the response and ensure that the data is in the expected format. Requests are also serialized with a dataclass model to ensure that there are no breaking changes when changes are made to the library. The data models are located in the `models` folder in separate models. The [`base_models`][pyvesync.models.base_models] module contains a dataclass holding the default values that do not change between library changes. The `base_models` module is imported into all other models to ensure that the default values stay consistent. The `base_models` module also contains base models that can be inherited for easy configuration and common fields. ```python @dataclass class ResponseBaseModel(DataClassORJSONMixin): """Base response model for API responses.""" class Config(BaseConfig): """orjson config for dataclasses.""" orjson_options = orjson.OPT_NON_STR_KEYS forbid_extra_keys = False ``` `ResponseCodeModel` - Inherits from `ResponseBaseModel` and contains the base keys in most API responses: ```python @dataclass class ResponseCodeModel(ResponseBaseModel): """Model for the 'result' field in response.""" traceId: str code: int msg: str | None ``` Models for each device should be kept in the `data_models` folder with the appropriate device name: - [`bypass_models`][pyvesync.models.bypass_models] - See [`Development`](./development/index.md) for more information on bypass devices. - [`bulb_models`][pyvesync.models.bulb_models] - [`humidifier_models`][pyvesync.models.humidifier_models] - [`purifier_models`][pyvesync.models.purifier_models] - [`outlet_models`][pyvesync.models.outlet_models] - [`switch_models`][pyvesync.models.switch_models] - [`fan_models`][pyvesync.models.fan_models] - [`airfryer_models`][pyvesync.models.fryer_models] - [`thermostat_models`][pyvesync.models.thermostat_models] There are multiple levels to some requests with nested dictionaries. These must be defined in different classes: ```python # The ResponseESL100CWDeviceDetail inherits from ResponseCodeModel @dataclass class ResponseESL100CWDeviceDetail(ResponseCodeModel): """Response model for Etekcity bulb details.""" result: ResponseESL100CWDetailResult @dataclass class ResponseESL100CWLight(ResponseBaseModel): """ESL100CW Tunable Bulb Device Detail Response.""" action: str brightness: int = 0 colorTempe: int = 0 @dataclass class ResponseESL100CWDetailResult(ResponseBaseModel): """Result model for ESL100CW Tunable bulb details.""" light: ResponseESL100CWLight ``` This model parses the following json response: ```python from pyvesync.data_models.bulb_models import ResponseESL100CWDeviceDetail import orjson api_response = { "traceId": "12345678", "code": 0, "msg": "success", "module": None, "stacktrace": None, "result": { "light": { "action": "on", "brightness": 5, "colorTempe": 0 } } } # Response will already be in bytes, so this is unecessary response_bytes = orjson.dumps(api_response, options=orjson.OPT_NON_STR_KEYS) response_model = ResponseESL100CWDeviceDetail.from_json(response_bytes) result = response_model.result light_model = result.light print(light_model.action) # prints: on print(light_model.brightness) # prints: 5 print(light_model.colorTempe) # prints: 0 ``` ### Making API Calls The helper function `pyvesync.utils.helpers.Helpers.get_class_attributes` is used to fill the values of the API calls by looking up class attributes, such as `token` in the `VeSync` instance and `cid` in the device instance. It accepts a list of keys and pulls the values of each key as they are found in the passed object: ```python keys = ['token', 'accountId', 'cid', 'timeZone', 'countryCode'] manager_dict = get_class_attributes(manager, keys) # request_dict = {"token": "AUTH_TOKEN", "accountId": "ACCOUNT_ID"} device_dict = get_class_attributes(device, keys) # device_dict = {"cid": "DEVICE CID"} ``` It can also handle class methods for items such as traceId which need to be calculated each API call: ```python keys = ['traceId'] default_dict = get_class_attributes(DefaultValues, keys) # {"traceId": "TIMESTAMP"} # It can also handle underscores and different capitalization schemes # It will always return the format of the key being passed in: keys = ["trace_id", "AccountID"] request_dict = get_class_attributes(DefaultValues, keys) # {"trace_id": "TIMESTAMP"} manager_dict = get_class_attributes(manager, keys) # {"AccountID": "ACCOUNT ID"} ``` ## Device Container Devices are held in the [DeviceContainer][pyvesync.device_container.DeviceContainer] class in the `manager.devices` attribute. The `DeviceContainer` class is a singleton class, so only one instance can exist. The class inherits from `MutableSet` so it contains unique objects, with the ability to add and remove devices using the `add`, `remove` and `clear` methods. However, these methods only accept device objects. To simplify removing devices, there is the `remove_by_cid(cid: str)` method. To get devices by device name, use the `get_by_name(name: str)` method. There are two convenience methods `add_new_devices` and `remove_stale_devices` that accept the device list response model. The `DeviceContainer` object has a property for each product type that returns a list of devices. For example, `DeviceContainer.outlets` returns a list of all outlets product type devices. See [DeviceContainer](./development/device_container.md) for more information on the device container. ```python import asyncio from pyvesync import VeSync async def main(): async with VeSync(user, password) as manager: assert len(manager.devices) == 0 # No devices yet await manager.login() await manager.get_devices() # Pulls in devices assert len(manager.devices) > 0 # Devices are now in the container for device in manager.devices: print(device) # Prints all devices in the container manager.update() # Pull state into devices # also holds the product types as properties outlets = manager.devices.outlets # list of VeSyncOutlet objects switches = manager.devices.switches # list of VeSyncSwitch objects fans = manager.devices.fans # list of VeSyncFan objects bulbs = manager.devices.bulbs # list of VeSyncBulb objects humidifiers = manager.devices.humidifiers # VeSyncHumid objects air_purifiers = manager.devices.air_purifiers # list of VeSyncPurifier objects if __name__ == '__main__': asyncio.run(main()) ``` ## Device Base Classes The device classes are now all inherited from their own product type specific base class. All base classes still inherit from [`vesyncbasedevice`][pyvesync.base_devices.vesyncbasedevice.VeSyncBaseDevice]. The base class provides the common functionality for all devices and the device classes provide the specific functionality for each device. The `pyvesync.base_devices` module contains the base classes for all devices in their respective modules by product type. The base state class is [`DeviceState`][pyvesync.base_devices.vesyncbasedevice.DeviceState]. Both `VeSyncBaseDevice` and `DeviceState` are inherited by device and their state classes. The base module should hold all properties and methods that are common to all devices. The base module also contains the base devices state class. The base device state class holds all state attributes for all underlying devices. ## Device Configuration with device_map module All features and configuration options for devices are held in the `pyveysnc.device_map` module. Older versions of pyvesync held the device configuration in each device module, all of these have moved to the `device_map` module. Each product type has a dataclass structure that is used to define all of the configuration options for each type. The `device_map.get_device_config(device_type: str)` method is used to lookup the configuration dataclass instance by the `deviceType` value in the device list response. ## Constants All constants should be located in the [const][pyvesync.const] module, including default values. There should not be any constants defined in the code. Use `enum.StrEnum` or `enum.IntEnum` for Enum values. All device modes and feature names are defined in this module. `IntEnum` and `StrEnum` are preferred for device status and state because boolean only allows for two states. There is no way to tell if the state is not yet known or unsupported. The `IntFlag` and `StrFlag` classes are used to define attributes in the state class that may not be supported by all devices. ```python class IntFlag(IntEnum): """Integer flag to indicate if a device is not supported. This is used by data models as a default value for feature attributes that are not supported by all devices. The default value is -999. """ NOT_SUPPORTED = -999 def __str__(self) -> str: """Return string representation of IntFlag.""" return str(self.name) class StrFlag(StrEnum): """String flag to indicate if a device is not supported. This is used by data models as a default value for feature attributes that are not supported by all devices. The default value is "not_supported". """ NOT_SUPPORTED = "not_supported" ``` The string states that support 'on' and 'off' have helper methods that allow for easy conversion from bool and int values: ```python from pyvesync.const import DeviceStatus api_int = 1 api_bool = True device.state.device_status = DeviceStatus.from_int(api_int) assert device.state.device_status == DeviceStatus.ON device.state.device_status = DeviceStatus.from_bool(api_bool) assert device.state.device_status == DeviceStatus.ON api_int = int(device.state.device_status) assert api_int == 1 api_bool = bool(device.state.device_status) assert api_bool == True ``` **Note that this only works for on/off values.** webdjoe-pyvesync-eb8cecb/docs/stylesheets/000077500000000000000000000000001507433633000211545ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/docs/stylesheets/common.css000066400000000000000000000013111507433633000231520ustar00rootroot00000000000000.doc-label-async code { color: #fc7676; font-style: italic; } .md-typeset a.toc-permalink { font-size: 0.6em; color: #97bbff86; padding-left: 3px; } :root > * { --md-primary-fg-color: #002c5b; } .md-header__button.md-logo img, .md-header__button.md-logo svg { fill: #00c1bc; } /* Heading Styles */ .md-typeset h5 .doc-object-name { text-transform: none !important; } /* .md-typeset h3.doc-heading { font-size:1.2em; } .md-typeset h4.doc-heading { font-size:1.17em; } .md-typeset h5.doc-heading { font-size:1.0em; } */ .doc-labels .doc-label-instance-attribute code { color: #70d381; } .doc-labels .doc-label-property code { color: #d39977; }webdjoe-pyvesync-eb8cecb/docs/supported_devices.md000066400000000000000000000110131507433633000226450ustar00rootroot00000000000000# Supported Devices The VeSync API supports a variety of devices. The following is a list of devices that are supported by the VeSync API and the `pyvesync` library. The product type is the terminology used to identify the base device type. The `pyvesync` library uses the product type to instantiate the correct device class and organize devices. 1. Bulbs - [ESL100][pyvesync.devices.vesyncbulb.VeSyncBulbESL100] - Etekcity Dimmable Bulb - [ESL100CW][pyvesync.devices.vesyncbulb.VeSyncBulbESL100CW] - Etekcity Dimmable Tunable Bulb (Cool to Warm) - [ESL100MC][pyvesync.devices.vesyncbulb.VeSyncBulbESL100MC] - Etekcity Multicolor Dimmable Bulb - [Valceno Multicolor Smart Bulb][pyvesync.devices.vesyncbulb.VeSyncBulbValcenoA19MC] 2. Outlets - [Etekcity 7A Round Outlet][pyvesync.devices.vesyncoutlet.VeSyncOutlet7A] - [Etekcity 10A Round Outlet EU][pyvesync.devices.vesyncoutlet.VeSyncOutlet10A] - [Etekcity 10A Rount Outlet USA][pyvesync.devices.vesyncoutlet.VeSyncESW10USA] - [Etekcity 15A Rectangle Outlet][pyvesync.devices.vesyncoutlet.VeSyncOutlet15A] - [Etekcity 15A Outdoor Dual Outlet][pyvesync.devices.vesyncoutlet.VeSyncOutdoorPlug] - [Round Smart Outlet Series][pyvesync.devices.vesyncoutlet.VeSyncOutletBSDGO1] - WHOPLUG / GREENSUN 3. Switches - [ESWL01][pyvesync.devices.vesyncswitch.VeSyncWallSwitch] - Etekcity Wall Switch - [ESWL03][pyvesync.devices.vesyncswitch.VeSyncWallSwitch] - Etekcity 3-Way Switch - [ESWD16][pyvesync.devices.vesyncswitch.VeSyncDimmerSwitch] - Etekcity Dimmer Switch 4. Purifiers - [Everest Air][pyvesync.devices.vesyncpurifier.VeSyncAirBaseV2] - [Vital 200S/100S][pyvesync.devices.vesyncpurifier.VeSyncAirBaseV2] - [Core600s][pyvesync.devices.vesyncpurifier.VeSyncAirBypass] - [Core400s][pyvesync.devices.vesyncpurifier.VeSyncAirBypass] - [Core300s][pyvesync.devices.vesyncpurifier.VeSyncAirBypass] - [Core200S][pyvesync.devices.vesyncpurifier.VeSyncAirBypass] - [LV-PUR131S][pyvesync.devices.vesyncpurifier.VeSyncAir131] - [Sprout Air Purifier][pyvesync.devices.vesyncpurifier.VeSyncAirSprout] 5. Humidifiers - [Classic 200S][pyvesync.devices.vesynchumidifier.VeSyncHumid200S] - 2L Smart Humidifier - [Classic 300S][pyvesync.devices.vesynchumidifier.VeSyncHumid200300S] - 3L Smart Humidifier - [Dual 200S][pyvesync.devices.vesynchumidifier.VeSyncHumid200300S] - [LV600S][pyvesync.devices.vesynchumidifier.VeSyncHumid200300S] - 6L Smart Humidifier - [OasisMist 4.5L Humidifier][pyvesync.devices.vesynchumidifier.VeSyncHumid200300S] - [OasisMist 1000S Humidifier][pyvesync.devices.vesynchumidifier.VeSyncHumid1000S] - [Superior 6000S][pyvesync.devices.vesynchumidifier.VeSyncSuperior6000S] - 6L Smart Humidifier 6. Fans - [42" Tower Fan][pyvesync.devices.vesyncfan.VeSyncTowerFan] 7. Air Fryers - [CS137][pyvesync.devices.vesynckitchen.VeSyncAirFryer158] - 3.7qt Air Fryer - [CS158][pyvesync.devices.vesynckitchen.VeSyncAirFryer158] - 5.8qt Air Fryer 8. Thermostats - [Aura][pyvesync.devices.vesyncthermostat] Thermostat **Needs testing** ## Device Features ### Switches Switches have minimal features, the dimmer switch is the only switch that has additional functionality. | Device Name | Device Type | Dimmer | Plate Lighting | RGB Plate Lighting | | :------: | :----: | :----: | :----: | :----: | | Etekcity Wall Switch | ESWL01 | | | | | Etekcity 3-Way Switch | ESWL03 | | | | | Etekcity Dimmer Switch | ESWD16 | ✔ | ✔ | ✔ | ### Outlets | Device Name | Power Stats | Nightlight | | :------: | :----: | :----: | | 7A Round Outlet | ✔ | | | 10A Round EU Outlet | ✔ | | | 10A Round US Outlet | | | | 15A Rectangle Outlet | ✔ | ✔ | | 15A Outdoor Dual Outlet | ✔ | | | Round Smart Series | | | ### Purifiers | Device Name | PM2.5 | PM1.0 | PM10 | Vent Angle | Light Detection | | ------ | ----- | ----- | ----- | ----- | ----- | | Everest Air | ✔ | ✔ | ✔ | ✔ | ✔ | | Vital 200S/100S | ✔ | | | | ✔ | | Core600s | ✔ | | | | | | Core400s | ✔ | | | | | | Core300s | ✔ | | | | | | Core200s | ✔ | | | | | | LV-PUR131S | ✔ | | | | | ### Humidifiers | Device Name | Night Light | Warm Mist | | ------ |-------------| ----- | | Classic 200S | | | | Classic 300S | ✔ | ✔ | | Dual 200S | | | | LV600S | | ✔ | | OasisMist | | ✔ | | Superior 6000S | | ✔ | ### Fans Tower Fan - Fan Rotate ### Air Fryers Air Fryer - All supported features of CS137 and CS158 webdjoe-pyvesync-eb8cecb/docs/usage.md000066400000000000000000000207371507433633000202370ustar00rootroot00000000000000# Quick Start Guide This is a quick start guide to get you up and running with the library. Each device has it's own properties and methods that can be found on the specific [device pages](./devices/index.md). ## Installing ### On Linux You can install the library using pip or from source. The library is compatible with Python 3.11 and higher. Before either method of installation, make sure you have python 3.11 or later and the following packages installed: ```bash sudo apt install python3-venv python3-pip git ``` #### Install from source These are the directions for installing the library from source in a folder in the `$HOME` directory. Change the first line `cd ~` to the directory you want to install the library in if `$HOME` is not suitable. The git checkout line is used to switch to the appropriate branch. If you are using `master`, then you can skip this line. The `dev-2.0` branch is the latest development branch and may contain breaking changes. ```bash cd ~ git clone https://github.com/webdjoe/pyvesync.git cd pyvesync git checkout dev-2.0 ``` Then create a new virtual environment and install the library. ```bash # Starting in the pyvesync directory # Check Python version is 3.11 or higher - or python3.11 --version python3 --version # Create a new venv - or use python3.11 -m venv venv python3 -m venv venv # Activate the virtual environment source venv/bin/activate # Install the library pip install . ``` #### Install via pip from github This method installs the library or a specific branch from the repository. The `BRANCHNAME` is the name of the branch you want to install. ```bash cd ~ mkdir pyvesync && cd pyvesync # Check Python version is 3.11 or higher - or python3.11 --version python3 --version # Create a new venv python3 -m venv venv # Activate the venv on linux source venv/bin/activate # Install branch to be tested into new virtual environment: pip install git+https://github.com/webdjoe/pyvesync.git@dev-2.0 # Install a PR that has not been merged using the PR number: pip install git+https://github.com/webdjoe/pyvesync.git@refs/pull/{PR_NUMBER}/head # Or if you are installing the latest release: pip install pyvesync ``` ### On Windows You can install the library using pip or from source. The library is compatible with Python 3.11 and higher. The instructions are similar to the Linux instructions with a few differences. Before either method of installation, make sure you have python 3.11 or later and have git installed. You can download git from [git-scm.com](https://git-scm.com/downloads). #### Windows from source This method installs the library from source in a folder in the `%USERPROFILE%` directory. Change the first line `cd %USERPROFILE%` to the directory you want to install the library in if `%USERPROFILE%` is not suitable. ```powershell cd %USERPROFILE% git clone "https://github.com/webdjoe/pyvesync.git" cd pyvesync git checkout dev-2.0 # Check python version is 3.11 or higher python --version # or python3 --version # Create a new venv python -m venv venv # Activate the virtual environment .\venv\Scripts\activate.ps1 # If you are using PowerShell .\venv\Scripts\activate.bat # If you are using cmd.exe # Install the library pip install . ``` #### Windows via pip from github This method installs the library or a specific branch from the repository. The `BRANCHNAME` is the name of the branch you want to install. ```powershell cd %USERPROFILE% mkdir pyvesync && cd pyvesync python --version # Check python version is 3.11 or higher python -m venv venv # Create a new venv .\venv\Scripts\activate.ps1 # Activate the venv on PowerShell # OR .\venv\Scripts\activate.bat # Activate the venv on cmd.exe # Install branch to be tested into new virtual environment: pip install git+https://github.com/webdjoe/pyvesync.git@dev-2.0 # Install a PR that has not been merged using the PR number: pip install git+https://github.com/webdjoe/pyvesync.git@refs/pull/{PR_NUMBER}/head # Or if you are installing the latest release: pip install pyvesync ``` ## Running the Library The `pyvesync.VeSync` object is the main entrypoint of the library. It is an asynchronous context manager, so it must be used with `async with`. The `VeSync` object is the main object that is used to interact with the library. It is responsible for logging in, getting devices, and updating devices. It is referred to as `manager` in the examples. ```python import asyncio from pyvesync import VeSync async def main(): # Create a new VeSync object async with VeSync(username="EMAIL", password="PASSWORD") as manager: # To enable debugging manager.debug = True # Login to the VeSync account await manager.login() # Check if logged in assert manager.enabled # Get devices await manager.get_devices() # Update all devices await manager.update() # OR Iterate through devices and update individually for device in manager.outlets: await device.update() ``` Devices are held in the `manager.devices` attribute, which is the instance of [`DeviceContainer`][pyvesync.device_container.DeviceContainer] that acts as a mutable set. The entire device list can be iterated over or there are properties for each product type that return a list of those device instances. For example, to get all the outlets, use `manager.devices.outlets` or `manager.devices.purifiers`. ```python import asyncio from pyvesync import VeSync from pyvesync.const import DeviceStatus # This is a device constant StrEnum USER = "EMAIL" PASSWORD = "PASSWORD" async def main(): manager = VeSync(username=USER, password=PASSWORD) await manager.login() # Login to the VeSync account if not manager.enabled: print("Login failed") return await manager.get_devices() # Get devices await manager.update() # Update all devices for device in manager.devices: print(device) # Print the device name and type await device.update() # Update the device state, this is not required if `manager.update()` has already been called. print(device.state) # Print the device state # Turn on or off the device await device.turn_on() # Turn on the device await device.turn_off() # Turn off the device # Toggle the device state await device.toggle() # Toggle the device state for outlet in manager.devices.outlets: print(outlet) await outlet.update() # Update the outlet state this is not required if `manager.update()` has already been called. await outlet.turn_on() # Turn on the outlet await outlet.turn_off() # Turn off the outlet await outlet.toggle() # Toggle the outlet state await outlet.set_timer(10, DeviceStatus.OFF) # Set a timer to turn off the outlet in 10 seconds ``` For more information on the device methods and properties, see the [device pages](./devices/index.md). All devices have the ability to serialize the attributes and state with the `to_json()` and `to_jsonb()` methods. The `to_json()` method returns a JSON string, while the `to_jsonb()` method returns a JSON object. The `to_jsonb()` method is useful for storing the device state in a database or sending it to an API. ```python async def main(): # I've excluded the login and get_devices methods for simplicity. for outlet in manager.devices.outlets: print(outlet.to_json(state=True, indent=True)) # Print the JSON string of the outlet state print(outlet.to_jsonb(state=True)) # Print the JSON object of the outlet and state # Setting `state=False` will only show the static device attributes # State objects also have these methods print(outlet.state.to_json(state=True, indent=True)) # Print the JSON string of the outlet state print(outlet.state.to_jsonb(state=True)) # Print the JSON object of the outlet state ``` The `to_dict()` and `as_tuple()` methods are also available for each device and state object. The `to_dict()` method returns a dictionary of the device attributes, while the `as_tuple()` method returns a tuple of the device attributes. They have the same signature as the `to_json()` and `to_jsonb()` methods. ```python device_dict = outlet.to_dict(state=True) # Returns a dictionary of the outlet attributes device_tuple = outlet.as_tuple(state=True) # Returns a tuple of the outlet attributes ``` ## Device Specific Scripts This section contains scripts that illustrate the functionality of each product type. The scripts are not exhaustive and are meant to be used as a starting point for your own scripts. webdjoe-pyvesync-eb8cecb/mkdocs.yml000066400000000000000000000062011507433633000176520ustar00rootroot00000000000000site_name: "pyvesync" site_url: https://pyvesync.github.io/ repo_name: webdjoe/pyvesync repo_url: https://github.com/webdjoe/pyvesync theme: name: material locale: en icon: logo: fontawesome/solid/house-signal highlightjs: true palette: scheme: slate primary: blue grey accent: blue show_bases: true features: - search.suggest - navigation.indexes - search.highlight - toc.follow - navigation.top nav: - Home: index.md - Supported Devices: supported_devices.md - pyvesync V3: pyvesync3.md - Usage: usage.md - Development: - development/index.md - VeSync API: development/vesync_api.md - Data Models: development/data_models.md - DeviceMap: development/device_map.md - DeviceContainer: development/device_container.md - VeSyncDevice Base: development/vesync_device_base.md - Constants: development/constants.md - Contributing: development/contributing.md - Testing: development/testing.md - Utils: - Helpers: development/utils/helpers.md - Colors: development/utils/colors.md - Device Mixins: development/utils/device_mixins.md - Errors & Exceptions: development/utils/errors.md - Logging: development/utils/logging.md - Devices: - devices/index.md - Outlets: devices/outlets.md - Switches: devices/switches.md - Bulbs: devices/bulbs.md - Air Purifiers: devices/air_purifiers.md - Humidifiers: devices/humidifiers.md - Fans: devices/fans.md - Air Fryers: devices/kitchen.md - Thermostats: devices/thermostats.md markdown_extensions: - def_list - toc: permalink: ↲ permalink_class: toc-permalink slugify: !!python/object/apply:pymdownx.slugs.slugify kwds: case: lower baselevel: 1 - pymdownx.snippets - pymdownx.superfences - pymdownx.inlinehilite - pymdownx.fancylists - pymdownx.saneheaders - pymdownx.blocks.admonition - pymdownx.blocks.details - pymdownx.highlight: anchor_linenums: true use_pygments: true - attr_list # hooks: # - docs/hooks.py watch: - src/pyvesync extra_css: - stylesheets/common.css extra: version: provider: mike default: latest alias: true plugins: - mike: # These fields are all optional; the defaults are as below... alias_type: symlink deploy_prefix: '' canonical_version: latest version_selector: true # - git-revision-date-localized: # enable_creation_date: true - mkdocstrings: default_handler: python handlers: python: paths: ["src/"] options: show_submodules: true show_signature_annotations: true signature_crossrefs: true separate_signature: true extensions: - griffe_inherited_docstrings: merge: true - docstring_inheritance.griffe - scripts/docs_handler.py:InheritedNotation - griffe_warnings_deprecated: kind: danger title: Deprecated - search - autorefs: resolve_closest: true - include-markdown: preserve_includer_indent: false dedent: true webdjoe-pyvesync-eb8cecb/mypy.ini000066400000000000000000000000321507433633000173420ustar00rootroot00000000000000[mypy] python_version=3.12webdjoe-pyvesync-eb8cecb/pyproject.toml000066400000000000000000000270201507433633000205650ustar00rootroot00000000000000[build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] name = "pyvesync" version = "3.1.2" description = "pyvesync is a library to manage Etekcity Devices, Cosori Air Fryers, and Levoit Air Purifiers run on the VeSync app." readme = "README.md" requires-python = ">=3.11" license = { text = "MIT" } authors = [ { name = "Joe Trabulsy", email = "webdjoe@gmail.com" }, { name = "Mark Perdue"} ] keywords = ["iot", "vesync", "levoit", "etekcity", "cosori", "valceno"] classifiers = [ "License :: OSI Approved :: MIT License", "Intended Audience :: Developers", "Natural Language :: English", "Topic :: Home Automation" ] dependencies = [ "aiohttp>=3.8.1", "mashumaro[orjson]>=3.13.1", ] [project.urls] "Home Page" = "https://github.com/webdjoe/pyvesync" "Documentation" = "https://webdjoe.github.io/pyvesync/latest/" [project.optional-dependencies] dev = [ "pytest", "pytest-cov", "pyyaml", "pylint", "mypy", "ruff", "pre-commit", "tox", "requests", "aiofiles" ] docs = [ "mkdocstrings-python", "mkdocs", "ruff", "Pygments", "mkdocs-material", "mkdocstrings", "mkdocs-include-markdown-plugin", "mkdocs-material-extensions", "pymdown-extensions", "mike", "mkdocs-git-revision-date-localized-plugin", "docstring-inheritance", "mkdocs-autoref" ] [tool.setuptools.packages.find] where = ["src"] exclude = ["tests", "test*"] [tool.pytest.ini_options] addopts = "--tb=short --color=yes" testpaths = ["src/tests"] norecursedirs = ["*.egg-info", ".git", ".tox", "dist", "build", "venv"] pythonpath = "src" log_cli = true [tool.coverage.run] branch = true omit = [ "test/*", "src/tests/*", "testing_scripts/*" ] [tool.coverage.report] exclude_also = [ 'pragma: no cover', 'if self\\.debug', "def __repr__", "def __str__", "def __eq__", "def __hash__", "def firmware_update", "def get_config", "raise AssertionError", "raise NotImplementedError", "@(abc\\.)?abstractmethod", ] [tool.pylint.MAIN] persistent = false ignore-paths = ["^test\\.*$"] [tool.pylint.BASIC] class-const-naming-style = "any" [tool.pylint."MESSAGES CONTROL"] # Reasons disabled: # format - handled by ruff # locally-disabled - it spams too much # duplicate-code - unavoidable # cyclic-import - doesn't test if both import on load # abstract-class-little-used - prevents from setting right foundation # unused-argument - generic callbacks and setup methods create a lot of warnings # too-many-* - are not enforced for the sake of readability # too-few-* - same as too-many-* # abstract-method - with intro of async there are always methods missing # inconsistent-return-statements - doesn't handle raise # too-many-ancestors - it's too strict. # wrong-import-order - isort guards this # possibly-used-before-assignment - too many errors / not necessarily issues # --- # Pylint CodeStyle plugin # consider-using-namedtuple-or-dataclass - too opinionated # consider-using-assignment-expr - decision to use := better left to devs disable = [ "format", "abstract-method", "cyclic-import", "duplicate-code", "inconsistent-return-statements", "locally-disabled", "not-context-manager", "too-few-public-methods", "too-many-ancestors", "too-many-arguments", "too-many-instance-attributes", "too-many-lines", "too-many-locals", "too-many-public-methods", "too-many-boolean-expressions", "wrong-import-order", "consider-using-namedtuple-or-dataclass", "consider-using-assignment-expr", "possibly-used-before-assignment", # Handled by ruff # Ref: "await-outside-async", # PLE1142 "bad-str-strip-call", # PLE1310 "bad-string-format-type", # PLE1307 "bidirectional-unicode", # PLE2502 "continue-in-finally", # PLE0116 "duplicate-bases", # PLE0241 "misplaced-bare-raise", # PLE0704 "format-needs-mapping", # F502 "function-redefined", # F811 # Needed because ruff does not understand type of __all__ generated by a function # "invalid-all-format", # PLE0605 "invalid-all-object", # PLE0604 "invalid-character-backspace", # PLE2510 "invalid-character-esc", # PLE2513 "invalid-character-nul", # PLE2514 "invalid-character-sub", # PLE2512 "invalid-character-zero-width-space", # PLE2515 "logging-too-few-args", # PLE1206 "logging-too-many-args", # PLE1205 "missing-format-string-key", # F524 "mixed-format-string", # F506 "no-method-argument", # N805 "no-self-argument", # N805 "nonexistent-operator", # B002 "nonlocal-without-binding", # PLE0117 "not-in-loop", # F701, F702 "notimplemented-raised", # F901 "return-in-init", # PLE0101 "return-outside-function", # F706 "syntax-error", # E999 "too-few-format-args", # F524 "too-many-format-args", # F522 "too-many-star-expressions", # F622 "truncated-format-string", # F501 "undefined-all-variable", # F822 "undefined-variable", # F821 "used-prior-global-declaration", # PLE0118 "yield-inside-async-function", # PLE1700 "yield-outside-function", # F704 "anomalous-backslash-in-string", # W605 "assert-on-string-literal", # PLW0129 "assert-on-tuple", # F631 "bad-format-string", # W1302, F "bad-format-string-key", # W1300, F "bare-except", # E722 "binary-op-exception", # PLW0711 "cell-var-from-loop", # B023 # "dangerous-default-value", # B006, ruff catches new occurrences, needs more work "duplicate-except", # B014 "duplicate-key", # F601 "duplicate-string-formatting-argument", # F "duplicate-value", # F "eval-used", # S307 "exec-used", # S102 "expression-not-assigned", # B018 "f-string-without-interpolation", # F541 "forgotten-debug-statement", # T100 "format-string-without-interpolation", # F # "global-statement", # PLW0603, ruff catches new occurrences, needs more work "global-variable-not-assigned", # PLW0602 "implicit-str-concat", # ISC001 "import-self", # PLW0406 "inconsistent-quotes", # Q000 "invalid-envvar-default", # PLW1508 "keyword-arg-before-vararg", # B026 "logging-format-interpolation", # G "logging-fstring-interpolation", # G "logging-not-lazy", # G "misplaced-future", # F404 "named-expr-without-context", # PLW0131 "nested-min-max", # PLW3301 "pointless-statement", # B018 "raise-missing-from", # B904 "redefined-builtin", # A001 "try-except-raise", # TRY302 "unused-argument", # ARG001, we don't use it "unused-format-string-argument", #F507 "unused-format-string-key", # F504 "unused-import", # F401 "unused-variable", # F841 "useless-else-on-loop", # PLW0120 "wildcard-import", # F403 "bad-classmethod-argument", # N804 "consider-iterating-dictionary", # SIM118 "empty-docstring", # D419 "invalid-name", # N815 "line-too-long", # E501, disabled globally "missing-class-docstring", # D101 "missing-final-newline", # W292 "missing-function-docstring", # D103 "missing-module-docstring", # D100 "multiple-imports", #E401 "singleton-comparison", # E711, E712 "subprocess-run-check", # PLW1510 "superfluous-parens", # UP034 "ungrouped-imports", # I001 "unidiomatic-typecheck", # E721 "unnecessary-direct-lambda-call", # PLC3002 "unnecessary-lambda-assignment", # PLC3001 "unnecessary-pass", # PIE790 "unneeded-not", # SIM208 "useless-import-alias", # PLC0414 "wrong-import-order", # I001 "wrong-import-position", # E402 "comparison-of-constants", # PLR0133 "comparison-with-itself", # PLR0124 "consider-alternative-union-syntax", # UP007 "consider-merging-isinstance", # PLR1701 "consider-using-alias", # UP006 "consider-using-dict-comprehension", # C402 "consider-using-generator", # C417 "consider-using-get", # SIM401 "consider-using-set-comprehension", # C401 "consider-using-sys-exit", # PLR1722 "consider-using-ternary", # SIM108 "literal-comparison", # F632 "property-with-parameters", # PLR0206 "super-with-arguments", # UP008 "too-many-branches", # PLR0912 "too-many-return-statements", # PLR0911 "too-many-statements", # PLR0915 "trailing-comma-tuple", # COM818 "unnecessary-comprehension", # C416 "use-a-generator", # C417 "use-dict-literal", # C406 "use-list-literal", # C405 "useless-object-inheritance", # UP004 "useless-return", # PLR1711 "no-else-break", # RET508 "no-else-continue", # RET507 "no-else-raise", # RET506 "no-else-return", # RET505 "broad-except", # BLE001 "protected-access", # SLF001 "broad-exception-raised", # TRY002 "consider-using-f-string", # PLC0209 # "no-self-use", # PLR6301 # Optional plugin, not enabled # Handled by mypy # Ref: "abstract-class-instantiated", "arguments-differ", "assigning-non-slot", "assignment-from-no-return", "assignment-from-none", "bad-exception-cause", "bad-format-character", "bad-reversed-sequence", "bad-super-call", "bad-thread-instantiation", "catching-non-exception", "comparison-with-callable", "deprecated-class", "dict-iter-missing-items", "format-combined-specification", "global-variable-undefined", "import-error", "inconsistent-mro", "inherit-non-class", "init-is-generator", "invalid-class-object", "invalid-enum-extension", "invalid-envvar-value", "invalid-format-returned", "invalid-hash-returned", "invalid-metaclass", "invalid-overridden-method", "invalid-repr-returned", "invalid-sequence-index", "invalid-slice-index", "invalid-slots-object", "invalid-slots", "invalid-star-assignment-target", "invalid-str-returned", "invalid-unary-operand-type", "invalid-unicode-codec", "isinstance-second-argument-not-valid-type", "method-hidden", "misplaced-format-function", "missing-format-argument-key", "missing-format-attribute", "missing-kwoa", "no-member", "no-value-for-parameter", "non-iterator-returned", "non-str-assignment-to-dunder-name", "nonlocal-and-global", "not-a-mapping", "not-an-iterable", "not-async-context-manager", "not-callable", "not-context-manager", "overridden-final-method", "raising-bad-type", "raising-non-exception", "redundant-keyword-arg", "relative-beyond-top-level", "self-cls-assignment", "signature-differs", "star-needs-assignment-target", "subclassed-final-class", "super-without-brackets", "too-many-function-args", "typevar-double-variance", "typevar-name-mismatch", "unbalanced-dict-unpacking", "unbalanced-tuple-unpacking", "unexpected-keyword-arg", "unhashable-member", "unpacking-non-sequence", "unsubscriptable-object", "unsupported-assignment-operation", "unsupported-binary-operation", "unsupported-delete-operation", "unsupported-membership-test", "used-before-assignment", "using-final-decorator-in-unsupported-version", "wrong-exception-operation", ] enable = [ #"useless-suppression", # temporarily every now and then to clean them up "use-symbolic-message-instead", ] per-file-ignores = [ # redefined-outer-name: Tests reference fixtures in the test function # use-implicit-booleaness-not-comparison: Tests need to validate that a list # or a dict is returned "/tests/:redefined-outer-name,use-implicit-booleaness-not-comparison", ]webdjoe-pyvesync-eb8cecb/requirements-dev.txt000066400000000000000000000001121507433633000217020ustar00rootroot00000000000000pytest requests pytest-cov ruff pylint mypy pyyaml tox aiofiles pre-commitwebdjoe-pyvesync-eb8cecb/requirements-docs.txt000066400000000000000000000004031507433633000220570ustar00rootroot00000000000000mkdocs ruff mkdocs-material mkdocstrings mkdocstrings-python Pygments mkdocs-include-markdown-plugin mkdocs-material-extensions griffe-inherited-docstrings pymdown-extensions mike mkdocs-git-revision-date-localized-plugin docstring-inheritance mkdocs-autorefswebdjoe-pyvesync-eb8cecb/requirements.txt000066400000000000000000000000771507433633000211400ustar00rootroot00000000000000aiohttp>=3.8.1 mashumaro[orjson]>=3.13.1 python-dateutil>=2.8.1webdjoe-pyvesync-eb8cecb/ruff.toml000066400000000000000000000036061507433633000175140ustar00rootroot00000000000000exclude = [ ".bzr", ".direnv", ".eggs", ".git", ".git-rewrite", ".hg", ".ipynb_checkpoints", ".mypy_cache", ".nox", ".pants.d", ".pyenv", ".pytest_cache", ".pytype", ".ruff_cache", ".svn", ".tox", ".venv", ".vscode", "__pypackages__", "_build", "buck-out", "build", "dist", "node_modules", "site-packages", "venv", "test", "tests", ] line-length = 90 indent-width = 4 [lint] select = ["ALL"] ignore = [ "COM812", # Missing trailing comma - IGNORE "TRY003", # Avoid specifying long messages outside the exception class "EM101", # Exception must not use a string literal, assign to variable first "FBT001", # type hint positional argument - bool is not allowed - IGNORE "FBT003", # Bool positional argument - IGNORE "TD002", # Todo error - IGNORE "TD003", # Todo error - IGNORE "FIX002", # Fixme error - IGNORE "FBT002", # boolean-default-value-positional-argument - IGNORE "INP001", # Implicit Namespace error - IGNORE "TC003", # Bug in TYPE_CHECKING recommendation for dataclasses - IGNORE "D102", # Missing docstring in public method - HANDLED BY flake8-docstrings "EXE002", # Use of exec - IGNORE # "Q000", # Quotes ] # Todo: Add exception classes EM102, BLE001, EM101 # Todo: Fix import sorting issue I001 unfixable = ["B"] [lint.flake8-quotes] docstring-quotes = "double" inline-quotes = "single" [lint.per-file-ignores] "vesync.py" = ["F403"] "**/models/*" = ["N804", "N803", "ANN401", "N802", "N815"] "vesyncbulb.py" = ["PLR2004"] "**/errors.py" = ["S105"] "vesynchome.py" = ["PERF203", "BLE001"] "testing_scripts/device_configurations.py" = ["T201"] [lint.pep8-naming] extend-ignore-names = ["displayJSON"] [lint.pydocstyle] convention = "google" [lint.pylint] max-public-methods = 30 max-args = 6 [format] quote-style = "single" indent-style = "space" skip-magic-trailing-comma = false line-ending = "native" webdjoe-pyvesync-eb8cecb/scripts/000077500000000000000000000000001507433633000173375ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/scripts/docs_handler.py000066400000000000000000000056501507433633000223440ustar00rootroot00000000000000"""The Griffe extension.""" from __future__ import annotations import contextlib import logging from typing import TYPE_CHECKING, Any from griffe import ( AliasResolutionError, Docstring, DocstringSectionText, Extension, GriffeLoader, ) if TYPE_CHECKING: from griffe import Module, Object logger = logging.getLogger(__name__) def _docstring_above(obj: Object) -> str | None: with contextlib.suppress(IndexError, KeyError): for parent in obj.parent.mro(): # type: ignore[union-attr] if obj.name in parent.members and not parent.members[obj.name].is_alias: # Use parent of the parent object to avoid linking private methods return ( f'Inherited From [`{parent.members[obj.name].parent.name}`]' # type: ignore[union-attr] f'[{parent.members[obj.name].parent.canonical_path}]' # type: ignore[union-attr] ) return None def _inherit_docstrings( # noqa: C901 obj: Object, loader: GriffeLoader, *, seen: set[str] | None = None ) -> None: if seen is None: seen = set() # if obj.path in seen: # return # noqa: ERA001 seen.add(obj.path) if obj.is_module: for member in obj.members.values(): if not member.is_alias: with contextlib.suppress(AliasResolutionError): _inherit_docstrings(member, loader, seen=seen) # type: ignore[arg-type] elif obj.is_class: for member in obj.all_members.values(): if docstring_above := _docstring_above(member): # type: ignore[arg-type] if member.docstring is None: member.docstring = Docstring( '', parent=member, # type: ignore[arg-type] parser=loader.docstring_parser, parser_options=loader.docstring_options, ) sections = member.docstring.parsed # Prevent inserting duplicate docstrings if ( sections and sections[0].kind == 'text' and sections[0].value.startswith('Inherited From') ): continue sections.insert(0, DocstringSectionText(docstring_above)) # This adds the Inherited notation to the base class, can't # figure out why. if member.is_class: _inherit_docstrings(member, loader, seen=seen) # type: ignore[arg-type] class InheritedNotation(Extension): """Griffe extension for inheriting docstrings.""" def on_package_loaded( self, *, pkg: Module, loader: GriffeLoader, **kwargs: Any, # noqa: ANN401, ARG002 ) -> None: """Inherit docstrings from parent classes once the whole package is loaded.""" _inherit_docstrings(pkg, loader) webdjoe-pyvesync-eb8cecb/setup.cfg000077500000000000000000000001551507433633000174750ustar00rootroot00000000000000[flake8] max-line-length = 90 extend-ignore = D102,D401 [pycodestyle] max-line-length = 90 statistics = Truewebdjoe-pyvesync-eb8cecb/setup.py000066400000000000000000000001041507433633000173550ustar00rootroot00000000000000"""pyvesync setup script.""" from setuptools import setup setup() webdjoe-pyvesync-eb8cecb/src/000077500000000000000000000000001507433633000164375ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/src/pyvesync/000077500000000000000000000000001507433633000203175ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/src/pyvesync/__init__.py000077500000000000000000000001241507433633000224300ustar00rootroot00000000000000"""VeSync API Library.""" from pyvesync.vesync import VeSync __all__ = ['VeSync'] webdjoe-pyvesync-eb8cecb/src/pyvesync/auth.py000066400000000000000000000364361507433633000216460ustar00rootroot00000000000000"""VeSync Authentication Module.""" from __future__ import annotations import asyncio import logging from pathlib import Path from typing import TYPE_CHECKING import orjson from mashumaro.exceptions import MissingField, UnserializableDataError from pyvesync.const import DEFAULT_REGION, NON_EU_COUNTRY_CODES from pyvesync.models.vesync_models import ( RequestGetTokenModel, RequestLoginTokenModel, RespGetTokenResultModel, RespLoginTokenResultModel, ResponseLoginModel, ) from pyvesync.utils.errors import ( ErrorCodes, ErrorTypes, VeSyncAPIResponseError, VeSyncLoginError, VeSyncServerError, ) if TYPE_CHECKING: from pyvesync.vesync import VeSync logger = logging.getLogger(__name__) class VeSyncAuth: """VeSync Authentication Manager. Handles login, token management, and persistent storage of authentication credentials for VeSync API access. """ __slots__ = ( '_account_id', '_country_code', '_current_region', '_password', '_token', '_token_file_path', '_username', 'manager', ) def __init__( self, manager: VeSync, username: str, password: str, country_code: str = DEFAULT_REGION, ) -> None: """Initialize VeSync Authentication Manager. Args: manager: VeSync manager instance for API calls username: VeSync account username (email) password: VeSync account password country_code: Country code in ISO 3166 Alpha-2 format current_region: Current region code - determines API base URL token_file_path: Path to store/load authentication token Note: Either username/password or token/account_id must be provided. If token_file_path is provided, credentials will be saved/loaded automatically. """ self.manager = manager self._username = username self._password = password self._token: str | None = None self._account_id: str | None = None self._country_code = country_code.upper() self._current_region = self._country_code_to_region() self._token_file_path: Path | None = None def _country_code_to_region(self) -> str: """Convert country code to region string for API use.""" if self._country_code in NON_EU_COUNTRY_CODES: return 'US' return 'EU' @property def token(self) -> str: """Return VeSync API token.""" if self._token is None: raise AttributeError( 'Token not set, run VeSync.login or VeSync.set_token method' ) return self._token @property def account_id(self) -> str: """Return VeSync account ID.""" if self._account_id is None: raise AttributeError( 'Account ID not set, run VeSync.login or VeSync.set_token method' ) return self._account_id @property def country_code(self) -> str: """Return country code.""" return self._country_code @country_code.setter def country_code(self, value: str) -> None: """Set country code.""" self._country_code = value.upper() @property def current_region(self) -> str: """Return current region.""" return self._current_region @property def is_authenticated(self) -> bool: """Check if user is authenticated.""" return self._token is not None and self._account_id is not None def set_credentials( self, token: str, account_id: str, country_code: str, region: str, ) -> None: """Set authentication credentials. Args: token: Authentication token account_id: Account ID country_code: Country code in ISO 3166 Alpha-2 format region: Current region code """ self._token = token self._account_id = account_id self._country_code = country_code.upper() self._current_region = region async def reauthenticate(self) -> bool: """Re-authenticate using stored username and password. Returns: True if re-authentication successful, False otherwise """ self.clear_credentials() return await self.login() async def load_credentials_from_file( self, file_path: str | Path | None = None ) -> bool: """Load credentials from token file if path is set. If no path is provided, it will try to load from the users home directory and then the current working directory. """ locations = [Path.home() / '.vesync_auth', Path.cwd() / '.vesync_auth'] file_path_object: Path | None = None if file_path is None: for location in locations: if location.exists(): file_path_object = location break elif isinstance(file_path, str): file_path_object = Path(file_path) else: file_path_object = Path(file_path) if not file_path_object or not file_path_object.exists(): logger.debug('No token file found to load credentials') return False try: data = await asyncio.to_thread( Path(file_path_object).read_text, encoding='utf-8' ) data = orjson.loads(data) self._token = data['token'] self._account_id = data['account_id'] self._country_code = data['country_code'].upper() self._current_region = data['current_region'] logger.debug('Credentials loaded from file: %s', file_path) except orjson.JSONDecodeError as exc: logger.warning('Failed to load credentials from file: %s', exc) return False if self._token is None or self._account_id is None: logger.debug('Incomplete credentials in token file') self.manager.enabled = False return False self.manager.enabled = True return True def output_credentials(self) -> str | None: """Output current credentials as JSON string.""" if not self.is_authenticated: logger.debug('No credentials to output, not authenticated') return None credentials = { 'token': self._token, 'account_id': self._account_id, 'country_code': self._country_code, 'current_region': self._current_region, } try: return orjson.dumps(credentials).decode('utf-8') except orjson.JSONEncodeError as exc: logger.warning('Failed to serialize credentials: %s', exc) return None async def save_credentials_to_file(self, file_path: str | Path | None = None) -> None: """Save authentication credentials to file.""" if file_path is not None: file_path_object = Path(file_path) elif self._token_file_path is not None: file_path_object = self._token_file_path else: logger.debug('No token file path set, saving to default location') file_path_object = Path.home() / '.vesync_auth' if not self.is_authenticated: logger.debug('No credentials to save, not authenticated') return credentials = { 'token': self._token, 'account_id': self._account_id, 'country_code': self._country_code, } try: data = orjson.dumps(credentials).decode('utf-8') await asyncio.to_thread(file_path_object.write_text, data, encoding='utf-8') logger.debug('Credentials saved to file: %s', file_path_object) except (orjson.JSONEncodeError, OSError) as exc: logger.warning('Failed to save credentials to file: %s', exc) def clear_credentials(self) -> None: """Clear all stored credentials.""" self._token = None self._account_id = None # Remove token file if it exists if self._token_file_path and self._token_file_path.exists(): try: self._token_file_path.unlink() logger.debug('Token file deleted: %s', self._token_file_path) except OSError as exc: logger.warning('Failed to delete token file: %s', exc) async def login(self) -> bool: """Log into VeSync server using username/password or existing token. Returns: True if login successful, False otherwise Raises: VeSyncLoginError: If login fails due to invalid credentials VeSyncAPIResponseError: If API response is invalid VeSyncServerError: If server returns an error """ # Attempt username/password login if self._username and self._password: return await self._login_with_credentials() raise VeSyncLoginError( 'No valid authentication method available. ' 'Provide either username/password or valid token/account_id.' ) async def _login_with_credentials(self) -> bool: """Login using username and password. Returns: True if login successful Raises: VeSyncLoginError: If login fails VeSyncAPIResponseError: If API response is invalid """ if not isinstance(self._username, str) or len(self._username) == 0: raise VeSyncLoginError('Username must be a non-empty string') if not isinstance(self._password, str) or len(self._password) == 0: raise VeSyncLoginError('Password must be a non-empty string') try: # Step 1: Get authorization code auth_code = await self._get_authorization_code() # Step 2: Exchange authorization code for token await self._exchange_authorization_code(auth_code) logger.debug('Login successful for user: %s', self._username) except (VeSyncLoginError, VeSyncAPIResponseError, VeSyncServerError): raise except Exception as exc: msg = f'Unexpected error during login: {exc}' raise VeSyncLoginError(msg) from exc return True async def _get_authorization_code(self) -> str: """Get authorization code using username and password. Returns: Authorization code Raises: VeSyncAPIResponseError: If API response is invalid VeSyncLoginError: If authentication fails """ request_auth = RequestGetTokenModel( email=self._username, # type: ignore[arg-type] method='authByPWDOrOTM', password=self._password, # type: ignore[arg-type] ) resp_dict, _ = await self.manager.async_call_api( '/globalPlatform/api/accountAuth/v1/authByPWDOrOTM', 'post', json_object=request_auth, ) if resp_dict is None: raise VeSyncAPIResponseError('Error receiving response to auth request') if resp_dict.get('code') != 0: error_info = ErrorCodes.get_error_info(resp_dict.get('code')) resp_message = resp_dict.get('msg') if resp_message is not None: error_info.message = f'{error_info.message} ({resp_message})' msg = f'Authentication failed - {error_info.message}' raise VeSyncLoginError(msg) try: response_model = ResponseLoginModel.from_dict(resp_dict) except UnserializableDataError as exc: logger.debug('Error parsing auth response: %s', exc) raise VeSyncAPIResponseError('Error parsing authentication response') from exc except MissingField as exc: logger.debug('Error parsing auth response: %s', exc) raise VeSyncAPIResponseError('Error parsing authentication response') from exc result = response_model.result if not isinstance(result, RespGetTokenResultModel): raise VeSyncAPIResponseError('Invalid authentication response format') self._account_id = result.accountID return result.authorizeCode async def _exchange_authorization_code( self, auth_code: str, region_change_token: str | None = None, ) -> None: """Exchange authorization code for access token. Args: auth_code: Authorization code from first auth step region_change_token: Token for region change (retry scenario) Raises: VeSyncLoginError: If login fails VeSyncAPIResponseError: If API response is invalid """ request_login = RequestLoginTokenModel( method='loginByAuthorizeCode4Vesync', authorizeCode=auth_code, bizToken=region_change_token, userCountryCode=self._country_code, regionChange='lastRegion' if region_change_token else None, ) resp_dict, _ = await self.manager.async_call_api( '/user/api/accountManage/v1/loginByAuthorizeCode4Vesync', 'post', json_object=request_login, ) if resp_dict is None: raise VeSyncAPIResponseError('Error receiving response to login request') try: response_model = ResponseLoginModel.from_dict(resp_dict) if not isinstance(response_model.result, RespLoginTokenResultModel): raise VeSyncAPIResponseError('Invalid login response format') if response_model.code != 0: error_info = ErrorCodes.get_error_info(resp_dict.get('code')) # Handle cross region error by retrying with new region if error_info.error_type == ErrorTypes.CROSS_REGION: result = response_model.result self._country_code = result.countryCode self._current_region = result.currentRegion logger.debug( 'Cross-region error, retrying with country: %s', self._country_code, ) return await self._exchange_authorization_code( auth_code, region_change_token=result.bizToken ) resp_message = resp_dict.get('msg') if resp_message is not None: error_info.message = f'{error_info.message} ({resp_message})' msg = f'Login failed - {error_info.message}' # Raise appropriate exception based on error type if error_info.error_type == ErrorTypes.SERVER_ERROR: raise VeSyncServerError(msg) raise VeSyncLoginError(msg) result = response_model.result self._token = result.token self._account_id = result.accountID self._country_code = result.countryCode except (MissingField, UnserializableDataError) as exc: logger.debug('Error parsing login response: %s', exc) raise VeSyncAPIResponseError('Error parsing login response') from exc def __repr__(self) -> str: """Return string representation of VeSyncAuth.""" return ( f'VeSyncAuth(username={self._username!r}, ' f'country_code={self._country_code!r}, ' f'authenticated={self.is_authenticated})' ) webdjoe-pyvesync-eb8cecb/src/pyvesync/base_devices/000077500000000000000000000000001507433633000227335ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/src/pyvesync/base_devices/__init__.py000066400000000000000000000021261507433633000250450ustar00rootroot00000000000000"""Base devices and state classes for VeSync devices. This module contains the base classes for VeSync devices, as well as the state classes for each device type. The base classes are used to create the device objects, while the state classes are used to store the current state of the device. """ from .bulb_base import BulbState, VeSyncBulb from .fan_base import FanState, VeSyncFanBase from .fryer_base import FryerState, VeSyncFryer from .humidifier_base import HumidifierState, VeSyncHumidifier from .outlet_base import OutletState, VeSyncOutlet from .purifier_base import PurifierState, VeSyncPurifier from .switch_base import SwitchState, VeSyncSwitch from .vesyncbasedevice import DeviceState, VeSyncBaseDevice, VeSyncBaseToggleDevice __all__ = [ 'BulbState', 'DeviceState', 'FanState', 'FryerState', 'HumidifierState', 'OutletState', 'PurifierState', 'SwitchState', 'VeSyncBaseDevice', 'VeSyncBaseToggleDevice', 'VeSyncBulb', 'VeSyncFanBase', 'VeSyncFryer', 'VeSyncHumidifier', 'VeSyncOutlet', 'VeSyncPurifier', 'VeSyncSwitch', ] webdjoe-pyvesync-eb8cecb/src/pyvesync/base_devices/bulb_base.py000066400000000000000000000270211507433633000252250ustar00rootroot00000000000000"""Base classes for all VeSync bulbs.""" from __future__ import annotations import logging from typing import TYPE_CHECKING from typing_extensions import deprecated from pyvesync.base_devices.vesyncbasedevice import DeviceState, VeSyncBaseToggleDevice from pyvesync.const import BulbFeatures from pyvesync.utils.colors import HSV, RGB, Color from pyvesync.utils.helpers import Converters, Validators if TYPE_CHECKING: from pyvesync import VeSync from pyvesync.device_map import BulbMap from pyvesync.models.vesync_models import ResponseDeviceDetailsModel logger = logging.getLogger(__name__) class BulbState(DeviceState): """VeSync Bulb State Base. Base class to hold all state attributes for bulb devices. Inherits from [`DeviceState`][pyvesync.base_devices.vesyncbasedevice.DeviceState]. This class should not be used directly for devices, but rather subclassed for each bulb type. Args: device (VeSyncBulb): VeSync Bulb device. details (ResponseDeviceDetailsModel): Device details from API. feature_map (BulbMap): Feature map for bulb. Attributes: _exclude_serialization (list[str]): List of attributes to exclude from serialization. active_time (int): Active time of device, defaults to None. connection_status (str): Connection status of device. device (VeSyncBaseDevice): Device object. device_status (str): Device status. features (dict): Features of device. last_update_ts (int): Last update timestamp of device, defaults to None. brightness (int): Brightness of bulb (0-100). color_temp (int): White color temperature of bulb in percent (0-100). color_temp_kelvin (int): White color temperature of bulb in Kelvin. color (Color): Color of bulb in the form of a dataclass with two namedtuple attributes - `hsv` & `rgb`. See [utils.colors.Colors][pyvesync.utils.colors.Color]. color_mode (str): Color mode of bulb. color_modes (list[str]): List of color modes supported by bulb. Methods: update_ts: Update last update timestamp. to_dict: Dump state to JSON. to_json: Dump state to JSON string. to_jsonb: Dump state to JSON bytes. as_tuple: Convert state to tuple of (name, value) tuples. See Also: - [`VeSyncBulb`][pyvesync.base_devices.bulb_base.VeSyncBulb] - [`ResponseDeviceDetailsModel`][ pyvesync.models.device_list_models.ResponseDeviceDetailsModel] - [`BulbMap`][pyvesync.device_map.BulbMap] """ __slots__ = ('_brightness', '_color', '_color_temp', 'color_mode', 'color_modes') def __init__( self, device: VeSyncBulb, details: ResponseDeviceDetailsModel, feature_map: BulbMap, ) -> None: """Initialize VeSync Bulb State Base.""" super().__init__(device, details, feature_map) self._exclude_serialization: list[str] = ['rgb', 'hsv'] self.features: list[str] = feature_map.features self.color_modes: list[str] = feature_map.color_modes self.device: VeSyncBulb = device self.color_mode: str | None = None self._brightness: int | None = None self._color_temp: int | None = None self._color: Color | None = None @property def color(self) -> Color | None: """Return color of bulb.""" return self._color @color.setter def color(self, color: Color | None) -> None: """Set color of bulb.""" self._color = color @property def hsv(self) -> HSV | None: """Return color of bulb as HSV.""" if self._color is not None: return self._color.hsv return None @hsv.setter def hsv(self, hsv_object: HSV | None) -> None: """Set color property with HSV values.""" if hsv_object is None: self._color = None return self._color = Color(hsv_object) @property def rgb(self) -> RGB | None: """Return color of bulb as RGB.""" if self._color is not None: return self._color.rgb return None @rgb.setter def rgb(self, rgb_object: RGB | None) -> None: """Set color property with RGB values.""" if rgb_object is None: self._color = None return self._color = Color(rgb_object) @property def brightness(self) -> int | None: """Brightness of vesync bulb 0-100.""" if not self.device.supports_brightness: logger.warning('Brightness not supported by this bulb') return None return self._brightness @brightness.setter def brightness(self, value: int | None) -> None: if not self.device.supports_brightness: logger.warning('Brightness not supported by this bulb') return if Validators.validate_zero_to_hundred(value): self._brightness = value else: self._brightness = None @property def color_temp(self) -> int | None: """White color temperature of bulb in percent (0-100).""" if not self.device.supports_color_temp: logger.warning('Color temperature not supported by this bulb') return None return self._color_temp @color_temp.setter def color_temp(self, value: int) -> None: if not self.device.supports_color_temp: logger.warning('Color temperature not supported by this bulb') return if Validators.validate_zero_to_hundred(value): self._color_temp = value else: logger.warning('Color temperature must be between 0 and 100') @property def color_temp_kelvin(self) -> int | None: """Return white color temperature of bulb in Kelvin.""" if not self.device.supports_color_temp: logger.warning('Color temperature not supported by this bulb') return None if self._color_temp is not None: return Converters.color_temp_pct_to_kelvin(self._color_temp) return None class VeSyncBulb(VeSyncBaseToggleDevice[BulbState]): """Base class for VeSync Bulbs. Abstract base class to provide methods for controlling and getting details of VeSync bulbs. Inherits from [`VeSyncBaseDevice`][pyvesync.base_devices.vesyncbasedevice.VeSyncBaseDevice]. This class should not be used directly for devices, but rather subclassed for each bulb. Args: details (ResponseDeviceDetailsModel): Device details from API. manager (VeSync): VeSync API manager. feature_map (BulbMap): Feature map for bulb. Attributes: state (BulbState): Device state object Each device has a separate state base class in the base_devices module. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: BulbMap ) -> None: """Initialize VeSync smart bulb base class.""" super().__init__(details, manager, feature_map) self.state: BulbState = BulbState(self, details, feature_map) @property def supports_brightness(self) -> bool: """Return True if bulb supports brightness.""" return BulbFeatures.DIMMABLE in self.features @property def supports_color_temp(self) -> bool: """Return True if bulb supports color temperature.""" return BulbFeatures.COLOR_TEMP in self.features @property def supports_multicolor(self) -> bool: """Return True if bulb supports backlight.""" return BulbFeatures.MULTICOLOR in self.features async def set_hsv(self, hue: float, saturation: float, value: float) -> bool: """Set HSV if supported by bulb. Args: hue (float): Hue 0-360 saturation (float): Saturation 0-100 value (float): Value 0-100 Returns: bool: True if successful, False otherwise. """ del hue, saturation, value if not self.supports_multicolor: logger.debug('Color mode is not supported on this bulb.') else: logger.debug('set_hsv not configured for %s bulb', self.device_type) return False async def set_rgb(self, red: float, green: float, blue: float) -> bool: """Set RGB if supported by bulb. Args: red (float): Red 0-255 green (float): green 0-255 blue (float): blue 0-255 Returns: bool: True if successful, False otherwise. """ del red, green, blue if not self.supports_multicolor: logger.debug('Color mode is not supported on this bulb.') else: logger.debug('set_rgb not configured for %s bulb', self.device_type) return False async def set_brightness(self, brightness: int) -> bool: """Set brightness if supported by bulb. Args: brightness (NUMERIC_T): Brightness 0-100 Returns: bool: True if successful, False otherwise. """ del brightness logger.warning('Brightness not supported/configured by this bulb') return False async def set_color_temp(self, color_temp: int) -> bool: """Set color temperature if supported by bulb. Args: color_temp (int): Color temperature 0-100 Returns: bool: True if successful, False otherwise. """ del color_temp if self.supports_color_temp: logger.debug('Color temperature is not configured on this bulb.') else: logger.debug('Color temperature not supported by this bulb') return False async def set_white_mode(self) -> bool: """Set white mode if supported by bulb. Returns: bool: True if successful, False otherwise. """ if self.supports_multicolor: logger.debug('White mode is not configured on this bulb.') else: logger.warning('White mode not supported by this bulb') return False async def set_color_mode(self, color_mode: str) -> bool: """Set color mode if supported by bulb. Args: color_mode (str): Color mode to set. Returns: bool: True if successful, False otherwise. """ del color_mode if self.supports_multicolor: logger.debug('Color mode is not configured on this bulb.') else: logger.warning('Color mode not supported by this bulb') return False @deprecated('Use `set_white_mode` instead.') async def enable_white_mode(self) -> bool: """Enable white mode if supported by bulb.""" return await self.set_white_mode() webdjoe-pyvesync-eb8cecb/src/pyvesync/base_devices/fan_base.py000066400000000000000000000303441507433633000250470ustar00rootroot00000000000000"""Fan Devices Base Class (NOT purifiers or humidifiers).""" from __future__ import annotations import logging from abc import abstractmethod from typing import TYPE_CHECKING from typing_extensions import deprecated from pyvesync.base_devices.vesyncbasedevice import DeviceState, VeSyncBaseToggleDevice from pyvesync.const import FanFeatures, FanModes if TYPE_CHECKING: from pyvesync import VeSync from pyvesync.device_map import FanMap from pyvesync.models.vesync_models import ResponseDeviceDetailsModel logger = logging.getLogger(__name__) class FanState(DeviceState): """Base state class for Purifiers. Not all attributes are supported by all devices. Attributes: display_set_status (str): Display set status. display_status (str): Display status. displaying_type (str): Displaying type. fan_level (int): Fan level. fan_set_level (int): Fan set level. humidity (int): Humidity level. mode (str): Mode of device. mute_set_status (str): Mute set status. mute_status (str): Mute status. oscillation_set_status (str): Oscillation set status. oscillation_status (str): Oscillation status. sleep_change_fan_level (str): Sleep change fan level. sleep_fallasleep_remain (str): Sleep fall asleep remain. sleep_oscillation_switch (str): Sleep oscillation switch. sleep_preference_type (str): Sleep preference type. temperature (int): Temperature. thermal_comfort (int): Thermal comfort. timer (Timer): Timer object. """ __slots__ = ( 'display_set_status', 'display_status', 'displaying_type', 'fan_level', 'fan_set_level', 'humidity', 'mode', 'mute_set_status', 'mute_status', 'oscillation_set_status', 'oscillation_status', 'sleep_change_fan_level', 'sleep_fallasleep_remain', 'sleep_oscillation_switch', 'sleep_preference_type', 'temperature', 'thermal_comfort', ) def __init__( self, device: VeSyncFanBase, details: ResponseDeviceDetailsModel, feature_map: FanMap, ) -> None: """Initialize Purifier State. Args: device (VeSyncFanBase): Device object. details (ResponseDeviceDetailsModel): Device details. feature_map (FanMap): Feature map. """ super().__init__(device, details, feature_map) self.mode: str = FanModes.UNKNOWN self.fan_level: int | None = None self.fan_set_level: int | None = None self.humidity: int | None = None self.temperature: int | None = None self.thermal_comfort: int | None = None self.sleep_preference_type: str | None = None self.sleep_fallasleep_remain: str | None = None self.sleep_oscillation_switch: str | None = None self.sleep_change_fan_level: str | None = None self.mute_status: str | None = None self.mute_set_status: str | None = None self.oscillation_status: str | None = None self.oscillation_set_status: str | None = None self.display_status: str | None = None self.display_set_status: str | None = None self.displaying_type: str | None = None class VeSyncFanBase(VeSyncBaseToggleDevice): """Base device for VeSync tower fans. Inherits from [VeSyncBaseToggleDevice][pyvesync.base_devices.vesyncbasedevice.VeSyncBaseToggleDevice] and [VeSyncBaseDevice][pyvesync.base_devices.vesyncbasedevice.VeSyncBaseDevice]. Attributes: fan_levels (list[int]): Fan levels supported by device. modes (list[str]): Modes supported by device. sleep_preferences (list[str]): Sleep preferences supported by device. """ __slots__ = ( 'fan_levels', 'modes', 'sleep_preferences', ) def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: FanMap, ) -> None: """Initialize VeSync Tower Fan Base Class. Args: details (ResponseDeviceDetailsModel): Device details. manager (VeSync): Manager. feature_map (FanMap): Feature map. See Also: See [device_map][pyvesync.device_map] for configured features and modes. """ super().__init__(details, manager, feature_map) self.features: list[str] = feature_map.features self.state: FanState = FanState(self, details, feature_map) self.modes: list[str] = feature_map.modes self.fan_levels: list[int] = feature_map.fan_levels self.sleep_preferences: list[str] = feature_map.sleep_preferences @property def supports_oscillation(self) -> bool: """Return True if device supports oscillation.""" return FanFeatures.OSCILLATION in self.features @property def supports_mute(self) -> bool: """Return True if device supports mute.""" return FanFeatures.SOUND in self.features @property def supports_displaying_type(self) -> bool: """Return True if device supports displaying type.""" return FanFeatures.DISPLAYING_TYPE in self.features async def toggle_display(self, toggle: bool) -> bool: """Toggle Display on/off. Args: toggle (bool): Display state. Returns: bool: Success of request. """ del toggle return False async def turn_on_display(self) -> bool: """Turn on Display. Returns: bool: Success of request """ return await self.toggle_display(True) async def turn_off_display(self) -> bool: """Turn off Display. Returns: bool: Success of request """ return await self.toggle_display(False) @abstractmethod async def set_mode(self, mode: str) -> bool: """Set Purifier Mode. Args: mode (str): Mode to set, varies by device type. Returns: bool: Success of request. """ @abstractmethod async def set_fan_speed(self, speed: int | None = None) -> bool: """Set Purifier Fan Speed. Args: speed (int, optional): Fan speed level according to device specs. Returns: bool: Success of request. """ async def set_auto_mode(self) -> bool: """Set Purifier to Auto Mode. Returns: bool: Success of request. Note: This method is not supported by all devices, will return false with warning debug message if not supported. """ if FanModes.AUTO in self.modes: return await self.set_mode(FanModes.AUTO) logger.warning('Auto mode not supported for this device.') return False async def set_advanced_sleep_mode(self) -> bool: """Set Purifier to Advanced Sleep Mode. Returns: bool: Success of request. Note: This method is not supported by all devices, will return false with warning debug message if not supported. """ if FanModes.ADVANCED_SLEEP in self.modes: return await self.set_mode(FanModes.ADVANCED_SLEEP) logger.warning('Advanced Sleep mode not supported for this device.') return False async def set_sleep_mode(self) -> bool: """Set Purifier to Sleep Mode. This is also referred to as Advanced Sleep Mode on some devices. Returns: bool: Success of request. Note: This method is not supported by all devices, will return false with warning debug message if not supported. """ if FanModes.ADVANCED_SLEEP in self.modes: return await self.set_mode(FanModes.ADVANCED_SLEEP) logger.warning('Sleep mode not supported for this device.') return False async def set_manual_mode(self) -> bool: """Set Purifier to Manual Mode - Normal Mode. Returns: bool: Success of request. Note: This method is not supported by all devices, will return false with warning debug message if not supported. """ if FanModes.NORMAL in self.modes: return await self.set_mode(FanModes.NORMAL) logger.warning('Manual mode not supported for this device.') return False async def set_normal_mode(self) -> bool: """Set Purifier to Normal Mode. Returns: bool: Success of request. Note: This method is not supported by all devices, will return false with warning debug message if not supported. """ if FanModes.NORMAL in self.modes: return await self.set_mode(FanModes.NORMAL) logger.warning('Normal mode not supported for this device.') return False async def set_turbo_mode(self) -> bool: """Set Purifier to Turbo Mode. Returns: bool: Success of request. Note: This method is not supported by all devices, will return false with warning debug message if not supported. """ if FanModes.TURBO in self.modes: return await self.set_mode(FanModes.TURBO) logger.warning('Turbo mode not supported for this device.') return False async def toggle_oscillation(self, toggle: bool) -> bool: """Toggle Oscillation on/off. Args: toggle (bool): Oscillation state. Returns: bool: true if success. """ del toggle if self.supports_oscillation: logger.debug('Oscillation not configured for this device.') else: logger.debug('Oscillation not supported for this device.') return False async def turn_on_oscillation(self) -> bool: """Set toggle_oscillation to on.""" return await self.toggle_oscillation(True) async def turn_off_oscillation(self) -> bool: """Set toggle_oscillation to off.""" return await self.toggle_oscillation(False) async def toggle_mute(self, toggle: bool) -> bool: """Toggle mute on/off. Parameters: toggle (bool): True to turn mute on, False to turn off Returns: bool : True if successful, False if not """ del toggle if self.supports_mute: logger.debug('Mute not configured for this device.') else: logger.debug('Mute not supported for this device.') return False async def turn_on_mute(self) -> bool: """Set toggle_mute to on.""" return await self.toggle_mute(True) async def turn_off_mute(self) -> bool: """Set toggle_mute to off.""" return await self.toggle_mute(False) async def toggle_displaying_type(self, toggle: bool) -> bool: """Toggle displaying type on/off. This functionality is unknown but was in the API calls. Args: toggle (bool): Displaying type state. Returns: bool: true if success. """ del toggle if self.supports_displaying_type: logger.debug('Displaying type not configured for this device.') else: logger.debug('Displaying type not supported for this device.') return False @deprecated('Use `set_normal_mode` method instead') async def normal_mode(self) -> bool: """Set mode to normal.""" return await self.set_normal_mode() @deprecated('Use `set_manual_mode` method instead') async def manual_mode(self) -> bool: """Adapter to set mode to normal.""" return await self.set_normal_mode() @deprecated('Use `set_advanced_sleep_mode` method instead') async def advanced_sleep_mode(self) -> bool: """Set advanced sleep mode.""" return await self.set_mode('advancedSleep') @deprecated('Use `set_sleep_mode` method instead') async def sleep_mode(self) -> bool: """Adapter to set advanced sleep mode.""" return await self.set_advanced_sleep_mode() @deprecated('Use `set_mode` method instead') async def mode_toggle(self, mode: str) -> bool: """Set mode to specified mode.""" return await self.set_mode(mode) webdjoe-pyvesync-eb8cecb/src/pyvesync/base_devices/fryer_base.py000066400000000000000000000035321507433633000254310ustar00rootroot00000000000000"""Air Purifier Base Class.""" from __future__ import annotations import logging from typing import TYPE_CHECKING from pyvesync.base_devices.vesyncbasedevice import DeviceState, VeSyncBaseDevice if TYPE_CHECKING: from pyvesync import VeSync from pyvesync.device_map import AirFryerMap from pyvesync.models.vesync_models import ResponseDeviceDetailsModel logger = logging.getLogger(__name__) class FryerState(DeviceState): """State class for Air Fryer devices. Note: This class is a placeholder for future functionality and does not currently implement any specific features or attributes. """ __slots__ = () def __init__( self, device: VeSyncFryer, details: ResponseDeviceDetailsModel, feature_map: AirFryerMap, ) -> None: """Initialize FryerState. Args: device (VeSyncFryer): The device object. details (ResponseDeviceDetailsModel): The device details. feature_map (AirFryerMap): The feature map for the device. """ super().__init__(device, details, feature_map) self.device: VeSyncFryer = device self.features: list[str] = feature_map.features class VeSyncFryer(VeSyncBaseDevice): """Base class for VeSync Air Fryer devices.""" __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: AirFryerMap, ) -> None: """Initialize VeSyncFryer. Args: details (ResponseDeviceDetailsModel): The device details. manager (VeSync): The VeSync manager. feature_map (AirFryerMap): The feature map for the device. Note: This is a bare class as there is only one supported air fryer model. """ super().__init__(details, manager, feature_map) webdjoe-pyvesync-eb8cecb/src/pyvesync/base_devices/humidifier_base.py000066400000000000000000000356351507433633000264400ustar00rootroot00000000000000"""Base Device and State Class for VeSync Humidifiers.""" from __future__ import annotations import logging from abc import abstractmethod from typing import TYPE_CHECKING from typing_extensions import deprecated from pyvesync.base_devices.vesyncbasedevice import DeviceState, VeSyncBaseToggleDevice from pyvesync.const import DeviceStatus, HumidifierFeatures, HumidifierModes if TYPE_CHECKING: from pyvesync import VeSync from pyvesync.device_map import HumidifierMap from pyvesync.models.vesync_models import ResponseDeviceDetailsModel logger = logging.getLogger(__name__) class HumidifierState(DeviceState): """State Class for VeSync Humidifiers. This is the state class for all vesync humidifiers. If there are new features or state attributes. Attributes: auto_preference (int): Auto preference level. auto_stop_target_reached (bool): Automatic stop target reached. auto_target_humidity (int): Target humidity level. automatic_stop_config (bool): Automatic stop configuration. display_set_status (str): Display set status. display_status (str): Display status. drying_mode_auto_switch (str): Drying mode auto switch status. drying_mode_level (int): Drying mode level. drying_mode_status (str): Drying mode status. drying_mode_time_remain (int): Drying mode time remaining. filter_life_percent (int): Filter life percentage. humidity (int): Current humidity level. humidity_high (bool): High humidity status. mist_level (int): Mist level. mist_virtual_level (int): Mist virtual level. mode (str): Current mode. nightlight_brightness (int): Nightlight brightness level. nightlight_status (str): Nightlight status. temperature (int): Current temperature. warm_mist_enabled (bool): Warm mist enabled status. warm_mist_level (int): Warm mist level. water_lacks (bool): Water lacks status. water_tank_lifted (bool): Water tank lifted status. """ __slots__ = ( 'auto_preference', 'auto_stop_target_reached', 'auto_target_humidity', 'automatic_stop_config', 'display_set_status', 'display_status', 'drying_mode_auto_switch', 'drying_mode_level', 'drying_mode_status', 'drying_mode_time_remain', 'filter_life_percent', 'humidity', 'humidity_high', 'mist_level', 'mist_virtual_level', 'mode', 'nightlight_brightness', 'nightlight_status', 'temperature', 'warm_mist_enabled', 'warm_mist_level', 'water_lacks', 'water_tank_lifted', ) def __init__( self, device: VeSyncHumidifier, details: ResponseDeviceDetailsModel, feature_map: HumidifierMap, ) -> None: """Initialize VeSync Humidifier State. This state class is used to store the current state of the humidifier. Args: device (VeSyncHumidifier): The device object. details (ResponseDeviceDetailsModel): The device details. feature_map (HumidifierMap): The feature map for the device. """ super().__init__(device, details, feature_map) self.auto_stop_target_reached: bool = False self.auto_target_humidity: int | None = None self.automatic_stop_config: bool = False self.display_set_status: str = DeviceStatus.UNKNOWN self.display_status: str = DeviceStatus.UNKNOWN self.humidity: int | None = None self.humidity_high: bool = False self.mist_level: int | None = None self.mist_virtual_level: int | None = None self.mode: str | None = None self.nightlight_brightness: int | None = None self.nightlight_status: str | None = None self.warm_mist_enabled: bool | None = None self.warm_mist_level: int | None = None self.water_lacks: bool = False self.water_tank_lifted: bool = False self.temperature: int | None = None # Superior 6000S States self.auto_preference: int | None = None self.filter_life_percent: int | None = None self.drying_mode_level: int | None = None self.drying_mode_auto_switch: str | None = None self.drying_mode_status: str | None = None self.drying_mode_time_remain: int | None = None @property def automatic_stop(self) -> bool: """Return the automatic stop status. Returns: bool: True if automatic stop is enabled, False otherwise. """ return self.automatic_stop_config @property @deprecated('Use auto_stop_target_reached instead.') def automatic_stop_target_reached(self) -> bool: """Deprecated function. Returns: bool: True if automatic stop target is reached, False otherwise. """ return self.auto_stop_target_reached @property def target_humidity(self) -> int | None: """Return the target humidity level. Returns: int: Target humidity level. """ return self.auto_target_humidity @property def auto_humidity(self) -> int | None: """Return the auto humidity level. Returns: int: Auto humidity level. """ return self.auto_target_humidity @property def auto_enabled(self) -> bool: """Return True if auto mode is enabled.""" return self.mode in [HumidifierModes.AUTO, self.mode, HumidifierModes.HUMIDITY] @property @deprecated('Use humidity property instead.') def humidity_level(self) -> int | None: """Deprecated function. Returns: int | None: Humidity level. """ return self.humidity @property def drying_mode_state(self) -> str | None: """Return the drying mode state. Returns: str | None: Drying mode state. """ return self.drying_mode_status @property def drying_mode_seconds_remaining(self) -> int | None: """Return the drying mode seconds remaining. Return: int | None: Drying mode seconds remaining. """ return self.drying_mode_time_remain @property def drying_mode_enabled(self) -> bool: """Return True if drying mode is enabled. Returns: bool | None: True if drying mode is enabled, False otherwise. """ return self.drying_mode_status == DeviceStatus.ON class VeSyncHumidifier(VeSyncBaseToggleDevice): """VeSyncHumdifier Base Class. This is the base device to be inherited by all Humidifier devices. This class only holds the device configuration and static attributes. The state attribute holds the current state. Attributes: state (HumidifierState): The state of the humidifier. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. mist_levels (list): List of mist levels. mist_modes (list): List of mist modes. target_minmax (tuple): Tuple of target min and max values. warm_mist_levels (list): List of warm mist levels. """ __slots__ = ( 'mist_levels', 'mist_modes', 'target_minmax', 'warm_mist_levels', ) def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: HumidifierMap, ) -> None: """Initialize VeSync Humidifier Class. Args: details (ResponseDeviceDetailsModel): The device details. manager (VeSync): The VeSync manager. feature_map (HumidifierMap): The feature map for the device. """ super().__init__(details, manager, feature_map) self.state: HumidifierState = HumidifierState(self, details, feature_map) self.mist_modes: dict[str, str] = feature_map.mist_modes self.mist_levels: list[str | int] = feature_map.mist_levels self.features: list[str] = feature_map.features self.warm_mist_levels: list[int | str] = feature_map.warm_mist_levels self.target_minmax: tuple[int, int] = feature_map.target_minmax @property def supports_warm_mist(self) -> bool: """Return True if the humidifier supports warm mist. Returns: bool: True if warm mist is supported, False otherwise. """ return HumidifierFeatures.WARM_MIST in self.features @property def supports_nightlight(self) -> bool: """Return True if the humidifier supports nightlight. Returns: bool: True if nightlight is supported, False otherwise. """ return HumidifierFeatures.NIGHTLIGHT in self.features @property def supports_nightlight_brightness(self) -> bool: """Return True if the humidifier supports nightlight brightness.""" return HumidifierFeatures.NIGHTLIGHT_BRIGHTNESS in self.features @property def supports_drying_mode(self) -> bool: """Return True if the humidifier supports drying mode.""" return HumidifierFeatures.DRYING_MODE in self.features async def toggle_automatic_stop(self, toggle: bool | None = None) -> bool: """Toggle automatic stop. Args: toggle (bool | None): True to enable automatic stop, False to disable. Returns: bool: Success of request. """ del toggle logger.warning('Automatic stop is not supported or configured for this device.') return False async def toggle_display(self, toggle: bool) -> bool: """Toggle the display on/off. Args: toggle (bool): True to turn on the display, False to turn off. Returns: bool: Success of request. """ del toggle logger.warning('Display is not supported or configured for this device.') return False @abstractmethod async def set_mode(self, mode: str) -> bool: """Set Humidifier Mode. Args: mode (str): Humidifier mode. Returns: bool: Success of request. Note: Modes for device are defined in `self.mist_modes`. """ @abstractmethod async def set_mist_level(self, level: int) -> bool: """Set Mist Level for Humidifier. Args: level (int): Mist level. Returns: bool: Success of request. Note: Mist levels are defined in `self.mist_levels`. """ async def turn_on_display(self) -> bool: """Turn on the display. Returns: bool: Success of request. """ return await self.toggle_display(True) async def turn_off_display(self) -> bool: """Turn off the display. Returns: bool: Success of request. """ return await self.toggle_display(False) async def turn_on_automatic_stop(self) -> bool: """Turn on automatic stop. Returns: bool: Success of request. """ return await self.toggle_automatic_stop(True) async def turn_off_automatic_stop(self) -> bool: """Turn off automatic stop. Returns: bool: Success of request. """ return await self.toggle_automatic_stop(False) async def set_auto_mode(self) -> bool: """Set Humidifier to Auto Mode. Returns: bool: Success of request. """ if HumidifierModes.AUTO in self.mist_modes: return await self.set_mode(HumidifierModes.AUTO) logger.debug('Auto mode not supported for this device.') return await self.set_mode(HumidifierModes.AUTO) async def set_manual_mode(self) -> bool: """Set Humidifier to Manual Mode. Returns: bool: Success of request. """ if HumidifierModes.MANUAL in self.mist_modes: return await self.set_mode(HumidifierModes.MANUAL) logger.debug('Manual mode not supported for this device.') return await self.set_mode(HumidifierModes.MANUAL) async def set_sleep_mode(self) -> bool: """Set Humidifier to Sleep Mode. Returns: bool: Success of request. """ if HumidifierModes.SLEEP in self.mist_modes: return await self.set_mode(HumidifierModes.SLEEP) logger.debug('Sleep mode not supported for this device.') return await self.set_mode(HumidifierModes.SLEEP) async def set_humidity(self, humidity: int) -> bool: """Set Humidifier Target Humidity. Args: humidity (int): Target humidity level. Returns: bool: Success of request. """ del humidity logger.debug('Target humidity is not supported or configured for this device.') return False async def set_nightlight_brightness(self, brightness: int) -> bool: """Set Humidifier night light brightness. Args: brightness (int): Target night light brightness. Returns: bool: Success of request. """ del brightness if not self.supports_nightlight_brightness: logger.debug('Nightlight brightness is not supported for this device.') return False logger.debug('Nightlight brightness has not been configured.') return False async def set_warm_level(self, warm_level: int) -> bool: """Set Humidifier Warm Level. Args: warm_level (int): Target warm level. Returns: bool: Success of request. """ del warm_level if self.supports_warm_mist: logger.debug('Warm level has not been configured.') return False logger.debug('Warm level is not supported for this device.') return False async def toggle_drying_mode(self, toggle: bool | None = None) -> bool: """enable/disable drying filters after turning off.""" del toggle if self.supports_drying_mode: logger.debug('Drying mode is not configured for this device.') return False logger.debug('Drying mode is not supported for this device.') return False webdjoe-pyvesync-eb8cecb/src/pyvesync/base_devices/outlet_base.py000066400000000000000000000304701507433633000256170ustar00rootroot00000000000000"""Base Devices for Outlets.""" from __future__ import annotations import logging from typing import TYPE_CHECKING from pyvesync.base_devices.vesyncbasedevice import DeviceState, VeSyncBaseToggleDevice from pyvesync.const import NightlightModes, OutletFeatures from pyvesync.models.base_models import DefaultValues from pyvesync.models.outlet_models import RequestEnergyHistory, ResponseEnergyHistory from pyvesync.utils.helpers import Helpers if TYPE_CHECKING: from pyvesync import VeSync from pyvesync.device_map import OutletMap from pyvesync.models.outlet_models import ResponseEnergyResult from pyvesync.models.vesync_models import ResponseDeviceDetailsModel logger = logging.getLogger(__name__) class OutletState(DeviceState): """Base state class for Outlets. This class holds all of the state information for the outlet devices. The state instance is stored in the `state` attribute of the outlet device. This is only for holding state information and does not contain any methods for controlling the device or retrieving information from the API. Args: device (VeSyncOutlet): The device object. details (ResponseDeviceDetailsModel): The device details. feature_map (OutletMap): The feature map for the device. Attributes: active_time (int): Active time of device, defaults to None. connection_status (str): Connection status of device. device (VeSyncOutlet): Device object. device_status (str): Device status. features (dict): Features of device. last_update_ts (int): Last update timestamp of device, defaults to None. energy (float): Energy usage in kWh. monthly_history (ResponseEnergyResult): Monthly energy history. nightlight_automode (str): Nightlight automode status. nightlight_brightness (int): Nightlight brightness level. nightlight_status (str): Nightlight status. power (float): Power usage in Watts. voltage (float): Voltage in Volts. weekly_history (ResponseEnergyResult): Weekly energy history. yearly_history (ResponseEnergyResult): Yearly energy history. Methods: update_ts: Update last update timestamp. to_dict: Dump state to JSON. to_json: Dump state to JSON string. to_jsonb: Dump state to JSON bytes. as_tuple: Convert state to tuple of (name, value) tuples. Note: Not all attributes are available on all devices. Some attributes may be None or not supported depending on the device type and features. The attributes are set based on the device features and the API response. """ __slots__ = ( 'energy', 'monthly_history', 'nightlight_automode', 'nightlight_brightness', 'nightlight_status', 'power', 'voltage', 'weekly_history', 'yearly_history', ) def __init__( self, device: VeSyncOutlet, details: ResponseDeviceDetailsModel, feature_map: OutletMap, ) -> None: """Initialize VeSync Switch State.""" super().__init__(device, details, feature_map) self._exclude_serialization = [ 'weakly_history', 'monthly_history', 'yearly_history', ] self.device: VeSyncOutlet = device self.features: list[str] = feature_map.features self.active_time: int | None = 0 self.power: float | None = None self.energy: float | None = None self.voltage: float | None = None self.nightlight_status: str | None = None self.nightlight_brightness: int | None = None self.nightlight_automode: str | None = None self.weekly_history: ResponseEnergyResult | None = None self.monthly_history: ResponseEnergyResult | None = None self.yearly_history: ResponseEnergyResult | None = None def annual_history_to_json(self) -> None | str: """Dump annual history.""" if not self.device.supports_energy: logger.info('Device does not support energy monitoring.') return None if self.yearly_history is None: logger.info('No yearly history available, run device.get_yearly_history().') return None return self.yearly_history.to_json() def monthly_history_to_json(self) -> None | str: """Dump monthly history.""" if not self.device.supports_energy: logger.info('Device does not support energy monitoring.') return None if self.monthly_history is None: logger.info('No monthly history available, run device.get_monthly_history().') return None return self.monthly_history.to_json() def weekly_history_to_json(self) -> None | str: """Dump weekly history.""" if not self.device.supports_energy: logger.info('Device does not support energy monitoring.') return None if self.weekly_history is None: logger.info('No weekly history available, run device.get_weekly_history().') return None return self.weekly_history.to_json() class VeSyncOutlet(VeSyncBaseToggleDevice): """Base class for Etekcity Outlets. State is stored in the `state` attribute of the device. This is only for holding state information and does not contain any methods for controlling the device or retrieving information from the API. Args: details (ResponseDeviceDetailsModel): The device details. manager (VeSync): The VeSync manager. feature_map (OutletMap): The feature map for the device. Attributes: state (OutletState): The state of the outlet. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. """ def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: OutletMap ) -> None: """Initialize VeSync Outlet base class.""" super().__init__(details, manager, feature_map) self.state: OutletState = OutletState(self, details, feature_map) self.nightlight_modes = feature_map.nightlight_modes def _build_energy_request(self, method: str) -> RequestEnergyHistory: """Build energy request post.""" request_keys = [ 'acceptLanguage', 'accountID', 'appVersion', 'phoneBrand', 'phoneOS', 'timeZone', 'token', 'traceId', 'userCountryCode', 'debugMode', 'homeTimeZone', 'uuid', ] body = Helpers.get_class_attributes(DefaultValues, request_keys) body.update(Helpers.get_class_attributes(self.manager, request_keys)) body.update(Helpers.get_class_attributes(self, request_keys)) body['method'] = method return RequestEnergyHistory.from_dict(body) async def _get_energy_history(self, history_interval: str) -> None: """Pull energy history from API. Args: history_interval (str): The interval for the energy history, options are 'getLastWeekEnergy', 'getLastMonthEnergy', 'getLastYearEnergy' Note: Builds the state._history attribute. """ if not self.supports_energy: logger.debug('Device does not support energy monitoring.') return history_intervals = [ 'getLastWeekEnergy', 'getLastMonthEnergy', 'getLastYearEnergy', ] if history_interval not in history_intervals: logger.debug('Invalid history interval: %s', history_interval) return body = self._build_energy_request(history_interval) headers = Helpers.req_header_bypass() r_bytes, _ = await self.manager.async_call_api( f'/cloud/v1/device/{history_interval}', 'post', headers=headers, json_object=body.to_dict(), ) r = Helpers.process_dev_response(logger, history_interval, self, r_bytes) if r is None: return response = ResponseEnergyHistory.from_dict(r) match history_interval: case 'getLastWeekEnergy': self.state.weekly_history = response.result case 'getLastMonthEnergy': self.state.monthly_history = response.result case 'getLastYearEnergy': self.state.yearly_history = response.result @property def supports_nightlight(self) -> bool: """Return True if device supports nightlight. Returns: bool: True if device supports nightlight, False otherwise. """ return OutletFeatures.NIGHTLIGHT in self.features @property def supports_energy(self) -> bool: """Return True if device supports energy. Returns: bool: True if device supports energy, False otherwise. """ return OutletFeatures.ENERGY_MONITOR in self.features async def get_weekly_energy(self) -> None: """Build weekly energy history dictionary. The data is stored in the `device.state.weekly_history` attribute as a `ResponseEnergyResult` object. """ await self._get_energy_history('getLastWeekEnergy') async def get_monthly_energy(self) -> None: """Build Monthly Energy History Dictionary. The data is stored in the `device.state.monthly_history` attribute as a `ResponseEnergyResult` object. """ await self._get_energy_history('getLastMonthEnergy') async def get_yearly_energy(self) -> None: """Build Yearly Energy Dictionary. The data is stored in the `device.state.yearly_history` attribute as a `ResponseEnergyResult` object. """ await self._get_energy_history('getLastYearEnergy') async def update_energy(self) -> None: """Build weekly, monthly and yearly dictionaries.""" if self.supports_energy: await self.get_weekly_energy() await self.get_monthly_energy() await self.get_yearly_energy() async def set_nightlight_state(self, mode: str) -> bool: """Set nightlight mode. Available nightlight states are found in the `device.nightlight_modes` attribute. Args: mode (str): Nightlight mode to set. Returns: bool: True if nightlight mode set successfully, False otherwise. """ del mode # unused if not self.supports_nightlight: logger.debug('Device does not support nightlight.') else: logger.debug('Nightlight mode not configured for %s', self.device_name) return False async def turn_on_nightlight(self) -> bool: """Turn on nightlight if supported.""" if not self.supports_nightlight: logger.debug('Device does not support nightlight.') return False return await self.set_nightlight_state(NightlightModes.ON) async def turn_off_nightlight(self) -> bool: """Turn off nightlight if supported.""" if not self.supports_nightlight: logger.debug('Device does not support nightlight.') return False return await self.set_nightlight_state(NightlightModes.OFF) async def set_nightlight_auto(self) -> bool: """Set nightlight to auto mode.""" if not self.supports_nightlight: logger.debug('Device does not support nightlight.') return False return await self.set_nightlight_state(NightlightModes.AUTO) webdjoe-pyvesync-eb8cecb/src/pyvesync/base_devices/purifier_base.py000066400000000000000000000445271507433633000261400ustar00rootroot00000000000000"""Air Purifier Base Class.""" from __future__ import annotations import logging from abc import abstractmethod from typing import TYPE_CHECKING from typing_extensions import deprecated from pyvesync.base_devices.vesyncbasedevice import DeviceState, VeSyncBaseToggleDevice from pyvesync.const import ( AirQualityLevel, DeviceStatus, NightlightModes, PurifierFeatures, PurifierModes, ) if TYPE_CHECKING: from pyvesync import VeSync from pyvesync.device_map import PurifierMap from pyvesync.models.vesync_models import ResponseDeviceDetailsModel logger = logging.getLogger(__name__) class PurifierState(DeviceState): """Base state class for Purifiers. Attributes: active_time (int): Active time of device, defaults to None. connection_status (str): Connection status of device. device (VeSyncOutlet): Device object. device_status (str): Device status. features (dict): Features of device. last_update_ts (int): Last update timestamp of device, defaults to None. mode (str): Current mode of the purifier. fan_level (int): Current fan level of the purifier. fan_set_level (int): Set fan level of the purifier. filter_life (int): Filter life percentage of the purifier. auto_preference_type (str): Auto preference type of the purifier. auto_room_size (int): Auto room size of the purifier. air_quality_level (AirQualityLevel): Air quality level of the purifier. child_lock (bool): Child lock status of the purifier. display_status (str): Display status of the purifier. display_set_status (str): Display set status of the purifier. display_forever (bool): Display forever status of the purifier. timer (Timer): Timer object for the purifier. pm25 (int): PM2.5 value of the purifier. pm1 (int): PM1 value of the purifier. pm10 (int): PM10 value of the purifier. aq_percent (int): Air quality percentage of the purifier. light_detection_switch (str): Light detection switch status of the purifier. light_detection_status (str): Light detection status of the purifier. nightlight_status (str): Nightlight status of the purifier. fan_rotate_angle (int): Fan rotate angle of the purifier. temperature (int): Temperature value of the purifier. humidity (int): Humidity value of the purifier. voc (int): VOC value of the purifier. co2 (int): CO2 value of the purifier. nightlight_brightness (int): Nightlight brightness level of the purifier. Note: Not all attributes are supported by all models. """ __slots__ = ( '_air_quality_level', 'aq_percent', 'auto_preference_type', 'auto_room_size', 'child_lock', 'co2', 'display_forever', 'display_set_status', 'display_status', 'fan_level', 'fan_rotate_angle', 'fan_set_level', 'filter_life', 'filter_open_state', 'humidity', 'light_detection_status', 'light_detection_switch', 'mode', 'nightlight_brightness', 'nightlight_status', 'pm1', 'pm10', 'pm25', 'temperature', 'voc', ) def __init__( self, device: VeSyncPurifier, details: ResponseDeviceDetailsModel, feature_map: PurifierMap, ) -> None: """Initialize Purifier State.""" super().__init__(device, details, feature_map) self.mode: str = PurifierModes.UNKNOWN self.fan_level: int | None = None self.fan_set_level: int | None = None self.filter_life: int | None = None self.auto_preference_type: str | None = None self.auto_room_size: int | None = None self._air_quality_level: AirQualityLevel | None = None self.child_lock: bool = False self.filter_open_state: bool = False self.display_status: str | None = None self.display_set_status: str | None = None self.display_forever: bool = False self.humidity: int | None = None self.temperature: int | None = None # Attributes not supported by all purifiers self.pm25: int | None = None self.pm1: int | None = None self.pm10: int | None = None self.aq_percent: int | None = None self.voc: int | None = None self.co2: int | None = None self.light_detection_switch: str | None = None self.light_detection_status: str | None = None self.nightlight_brightness: int | None = None self.nightlight_status: str | None = None self.fan_rotate_angle: int | None = None @property def air_quality_level(self) -> int: """Return air quality level in integer from 1-4. Returns -1 if unknown. """ if self._air_quality_level is None: return -1 return int(self._air_quality_level) @air_quality_level.setter def air_quality_level(self, value: int | None) -> None: """Set air quality level.""" if isinstance(value, int): self._air_quality_level = AirQualityLevel.from_int(value) def set_air_quality_level(self, value: int | str | None) -> None: """Set air quality level.""" if isinstance(value, str): self._air_quality_level = AirQualityLevel.from_string(value) elif isinstance(value, int): self._air_quality_level = AirQualityLevel.from_int(value) @property def air_quality_string(self) -> str: """Return air quality level as string.""" return str(self._air_quality_level) @property @deprecated('Use state.air_quality_level instead.') def air_quality(self) -> int | str | None: """Return air quality level.""" return self.air_quality_level @property @deprecated('Use light_detection_switch instead.') def light_detection(self) -> bool: """Return light detection status.""" return self.light_detection_switch == DeviceStatus.ON @property @deprecated('Use state.pm25 instead.') def air_quality_value(self) -> int | None: """Return air quality value.""" return self.pm25 @property @deprecated('Use PurifierState.fan_level instead.') def speed(self) -> int | None: """Return fan speed.""" return self.fan_level @property @deprecated('Use PurifierState.nightlight_status instead.') def night_light(self) -> str | None: """Return night light status.""" return self.nightlight_status @property @deprecated('Use display_status instead.') def display_state(self) -> str | None: """Return display status.""" return self.display_status @property @deprecated('Use display_set_status instead.') def display_switch(self) -> str | None: """Return display switch status.""" return self.display_set_status class VeSyncPurifier(VeSyncBaseToggleDevice): """Base device for vesync air purifiers. Args: details (ResponseDeviceDetailsModel): Device details from API. manager (VeSync): VeSync manager instance. feature_map (PurifierMap): Feature map for the device. Attributes: state (PurifierState): State of the device. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. modes (list[str]): List of modes supported by the device. fan_levels (list[int]): List of fan levels supported by the device. nightlight_modes (list[str]): List of nightlight modes supported by the device. auto_preferences (list[str]): List of auto preferences supported by the device. """ __slots__ = ('auto_preferences', 'fan_levels', 'modes', 'nightlight_modes') def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: PurifierMap, ) -> None: """Initialize VeSync Purifier Base Class.""" super().__init__(details, manager, feature_map) self.features: list[str] = feature_map.features self.state: PurifierState = PurifierState(self, details, feature_map) self.modes: list[str] = feature_map.modes self.fan_levels: list[int] = feature_map.fan_levels self.nightlight_modes: list[str] = feature_map.nightlight_modes self.auto_preferences: list[str] = feature_map.auto_preferences @property def supports_air_quality(self) -> bool: """Return True if device supports air quality.""" return PurifierFeatures.AIR_QUALITY in self.features @property def supports_fan_rotate(self) -> bool: """Return True if device supports fan rotation.""" return PurifierFeatures.VENT_ANGLE in self.features @property def supports_nightlight(self) -> bool: """Return True if device supports nightlight.""" return PurifierFeatures.NIGHTLIGHT in self.features @property def supports_light_detection(self) -> bool: """Returns True if device supports light detection.""" return PurifierFeatures.LIGHT_DETECT in self.features async def toggle_display(self, mode: bool) -> bool: """Set Display Mode.""" del mode raise NotImplementedError async def turn_on_display(self) -> bool: """Turn on Display.""" return await self.toggle_display(True) async def turn_off_display(self) -> bool: """Turn off Display.""" return await self.toggle_display(False) async def set_nightlight_mode(self, mode: str) -> bool: """Set Nightlight Mode. Modes are defined in the `device.nightlight_modes` attribute. Args: mode (str): Nightlight mode to set. Returns: bool: True if successful, False otherwise. """ del mode return False async def set_nightlight_dim(self) -> bool: """Set Nightlight Dim.""" return await self.set_nightlight_mode(NightlightModes.DIM) async def turn_on_nightlight(self) -> bool: """Turn on Nightlight.""" return await self.set_nightlight_mode(NightlightModes.ON) async def turn_off_nightlight(self) -> bool: """Turn off Nightlight.""" return await self.set_nightlight_mode(NightlightModes.OFF) async def toggle_child_lock(self, toggle: bool | None = None) -> bool: """Toggle Child Lock (Display Lock). Args: toggle (bool | None): Toggle child lock. If None, toggle state. """ del toggle logger.debug('Child lock not configured for this device.') return False async def turn_on_child_lock(self) -> bool: """Set child lock (display lock) to ON.""" return await self.toggle_child_lock(True) async def turn_off_child_lock(self) -> bool: """Set child lock (display lock) to OFF.""" return await self.toggle_child_lock(False) @abstractmethod async def set_mode(self, mode: str) -> bool: """Set Purifier Mode. Allowed modes are found in the `device.modes` attribute. Args: mode (str): Mode to set. Returns: bool: True if successful, False otherwise. """ @abstractmethod async def set_fan_speed(self, speed: int | None = None) -> bool: """Set Purifier Fan Speed. Args: speed (int | None): Fan speed to set. If None, use default speed. Returns: bool: True if successful, False otherwise. """ async def set_auto_mode(self) -> bool: """Set Purifier to Auto Mode.""" if PurifierModes.AUTO in self.modes: return await self.set_mode(PurifierModes.AUTO) logger.error('Auto mode not supported for this device.') return False async def set_sleep_mode(self) -> bool: """Set Purifier to Sleep Mode.""" if PurifierModes.SLEEP in self.modes: return await self.set_mode(PurifierModes.SLEEP) logger.error('Sleep mode not supported for this device.') return False async def set_manual_mode(self) -> bool: """Set Purifier to Manual Mode.""" if PurifierModes.MANUAL in self.modes: return await self.set_mode(PurifierModes.MANUAL) logger.error('Manual mode not supported for this device.') return False async def set_turbo_mode(self) -> bool: """Set Purifier to Turbo Mode.""" if PurifierModes.TURBO in self.modes: return await self.set_mode(PurifierModes.TURBO) logger.error('Turbo mode not supported for this device.') return False async def set_pet_mode(self) -> bool: """Set Purifier to Pet Mode.""" if PurifierModes.PET in self.modes: return await self.set_mode(PurifierModes.PET) logger.error('Pet mode not supported for this device.') return False async def set_auto_preference(self, preference: str, room_size: int = 800) -> bool: """Set auto preference. Args: preference (str): Auto preference to set, available preference is found in `self.auto_preferences`. room_size (int): Room size to set, defaults to 800ft2. Returns: bool: True if successful, False otherwise. """ del preference, room_size logger.debug('Auto preference not configured for this device.') return False async def toggle_light_detection(self, toggle: bool | None = None) -> bool: """Set Light Detection Mode. Args: toggle (bool | None): Toggle light detection. If None, toggle state. Returns: bool: True if successful, False otherwise. """ del toggle if not self.supports_light_detection: logger.debug('Light detection not supported for this device.') else: logger.debug('Light detection not configured for this device.') return False async def turn_on_light_detection(self) -> bool: """Turn on Light Detection.""" return await self.toggle_light_detection(True) async def turn_off_light_detection(self) -> bool: """Turn off Light Detection.""" return await self.toggle_light_detection(False) async def reset_filter(self) -> bool: """Reset filter life.""" logger.debug('Filter life reset not configured for this device.') return False @deprecated('Use set_auto_mode instead.') async def auto_mode(self) -> bool: """Set Purifier to Auto Mode.""" return await self.set_auto_mode() @deprecated('Use set_sleep_mode instead.') async def sleep_mode(self) -> bool: """Set Purifier to Sleep Mode.""" return await self.set_sleep_mode() @deprecated('Use set_manual_mode instead.') async def manual_mode(self) -> bool: """Set Purifier to Manual Mode.""" return await self.set_manual_mode() @deprecated('Use set_turbo_mode instead.') async def turbo_mode(self) -> bool: """Set Purifier to Turbo Mode.""" return await self.set_turbo_mode() @deprecated('Use set_pet_mode instead.') async def pet_mode(self) -> bool: """Set Purifier to Pet Mode.""" return await self.set_pet_mode() @deprecated('Use set_nightlight_mode instead.') async def nightlight_mode(self, mode: str) -> bool: """Set Nightlight Mode.""" return await self.set_nightlight_mode(mode) @deprecated('Use `set_fan_speed()` instead.') async def change_fan_speed(self, speed: int | None = None) -> bool: """Deprecated - Set fan speed.""" return await self.set_fan_speed(speed) @deprecated('Use `set_mode(mode: str)` instead.') async def change_mode(self, mode: str) -> bool: """Deprecated - Set purifier mode.""" return await self.set_mode(mode) @deprecated('Use `toggle_child_lock()` instead.') async def set_child_lock(self, toggle: bool) -> bool: """Set child lock (display lock). This has been deprecated in favor of `toggle_child_lock()`. """ return await self.toggle_child_lock(toggle) @deprecated('Use `turn_on_child_lock()` instead.') async def child_lock_on(self) -> bool: """Turn on child lock (display lock). This has been deprecated, use `turn_on_child_lock()` instead. """ return await self.toggle_child_lock(True) @deprecated('Use `turn_off_child_lock()` instead.') async def child_lock_off(self) -> bool: """Turn off child lock (display lock). This has been deprecated, use `turn_off_child_lock()` instead. """ return await self.toggle_child_lock(False) @property @deprecated('Use self.state.child_lock instead.') def child_lock(self) -> bool: """Get child lock state. Returns: bool : True if child lock is enabled, False if not. """ return self.state.child_lock @property @deprecated('Use self.state.nightlight_status instead.') def night_light(self) -> str | None: """Get night light state. Returns: str : Night light state (on, dim, off) """ return self.state.nightlight_status @deprecated('Use turn_on_light_detection() instead.') async def set_light_detection_on(self) -> bool: """Turn on light detection feature.""" return await self.toggle_light_detection(True) @deprecated('Use turn_off_light_detection() instead.') async def set_light_detection_off(self) -> bool: """Turn off light detection feature.""" return await self.toggle_light_detection(False) webdjoe-pyvesync-eb8cecb/src/pyvesync/base_devices/switch_base.py000066400000000000000000000235261507433633000256100ustar00rootroot00000000000000"""Base classes for all VeSync switches.""" from __future__ import annotations import logging from typing import TYPE_CHECKING from typing_extensions import deprecated from pyvesync.base_devices.vesyncbasedevice import DeviceState, VeSyncBaseToggleDevice from pyvesync.const import SwitchFeatures from pyvesync.utils.colors import Color if TYPE_CHECKING: from pyvesync import VeSync from pyvesync.device_map import SwitchMap from pyvesync.models.vesync_models import ResponseDeviceDetailsModel from pyvesync.utils.colors import HSV, RGB logger = logging.getLogger(__name__) class SwitchState(DeviceState): """VeSync Switch State Base. Args: device (VeSyncSwitch): The switch device. details (ResponseDeviceDetailsModel): The switch device details. feature_map (SwitchMap): The switch feature map. Attributes: _exclude_serialization (list[str]): List of attributes to exclude from serialization. active_time (int): Active time of device, defaults to None. connection_status (str): Connection status of device. device (VeSyncBaseDevice): Device object. device_status (str): Device status. features (dict): Features of device. last_update_ts (int): Last update timestamp of device, defaults to None. backlight_color (Color): The backlight color of the switch. brightness (int): The brightness level of the switch. backlight_status (str): The status of the backlight. indicator_status (str): The status of the indicator light. """ __slots__ = ( '_backlight_color', 'backlight_status', 'brightness', 'indicator_status', ) def __init__( self, device: VeSyncSwitch, details: ResponseDeviceDetailsModel, feature_map: SwitchMap, ) -> None: """Initialize VeSync Switch State.""" super().__init__(device, details, feature_map) self.device: VeSyncSwitch = device self._backlight_color: Color | None = None self.brightness: int | None = None self.active_time: int | None = 0 self.backlight_status: str | None = None self.indicator_status: str | None = None @property def backlight_rgb(self) -> RGB | None: """Get backlight RGB color.""" if not self.device.supports_backlight_color: logger.warning('Backlight color not supported.') if isinstance(self._backlight_color, Color): return self._backlight_color.rgb return None @property def backlight_hsv(self) -> HSV | None: """Get backlight HSV color.""" if not self.device.supports_backlight_color: logger.warning('Backlight color not supported.') if isinstance(self._backlight_color, Color): return self._backlight_color.hsv return None @property def backlight_color(self) -> Color | None: """Get backlight color.""" if isinstance(self._backlight_color, Color): return self._backlight_color logger.warning('Backlight color not supported.') return None @backlight_color.setter def backlight_color(self, color: Color | None) -> None: """Set backlight color.""" if not self.device.supports_backlight_color: logger.warning('Backlight color not supported.') return self._backlight_color = color class VeSyncSwitch(VeSyncBaseToggleDevice): """Etekcity Switch Base Class. Abstract Base Class for Etekcity Switch Devices, inheriting from pyvesync.base_devices.vesyncbasedevice.VeSyncBaseDevice. Should not be instantiated directly, subclassed by VeSyncWallSwitch and VeSyncDimmerSwitch. Attributes: state (SwitchState): Switch state object. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: SwitchMap ) -> None: """Initialize Switch Base Class.""" super().__init__(details, manager, feature_map) self.state: SwitchState = SwitchState(self, details, feature_map) @property @deprecated('Use `supports_dimmable` property instead.') def is_dimmable(self) -> bool: """Return True if switch is dimmable.""" return bool(SwitchFeatures.DIMMABLE in self.features) @property def supports_backlight_color(self) -> bool: """Return True if switch supports backlight.""" return bool(SwitchFeatures.BACKLIGHT_RGB in self.features) @property def supports_indicator_light(self) -> bool: """Return True if switch supports indicator.""" return bool(SwitchFeatures.INDICATOR_LIGHT in self.features) @property def supports_backlight(self) -> bool: """Return True if switch supports backlight.""" return bool(SwitchFeatures.BACKLIGHT in self.features) @property def supports_dimmable(self) -> bool: """Return True if switch is dimmable.""" return bool(SwitchFeatures.DIMMABLE in self.features) async def toggle_indicator_light(self, toggle: bool | None = None) -> bool: """Toggle indicator light on or off. Args: toggle (bool): True to turn on, False to turn off. If None, toggles the state Returns: bool: True if successful, False otherwise. """ del toggle if self.supports_indicator_light: logger.debug('toggle_indicator_light not configured for %s', self.device_name) else: logger.debug('toggle_indicator_light not supported for %s', self.device_name) return False async def turn_on_indicator_light(self) -> bool: """Turn on indicator light if supported.""" return await self.toggle_indicator_light(True) async def turn_off_indicator_light(self) -> bool: """Turn off indicator light if supported.""" return await self.toggle_indicator_light(False) async def set_backlight_status( self, status: bool, red: int | None = None, green: int | None = None, blue: int | None = None, ) -> bool: """Set the backlight status and optionally its color if supported by the device. Args: status (bool): Backlight status (True for ON, False for OFF). red (int | None): RGB green value (0-255), defaults to None. green (int | None): RGB green value (0-255), defaults to None. blue (int | None): RGB blue value (0-255), defaults to None. Returns: bool: True if successful, False otherwise. """ del status, red, green, blue if self.supports_backlight: logger.debug('set_backlight_status not configured for %s', self.device_name) else: logger.debug('set_backlight_status not supported for %s', self.device_name) return False async def turn_on_rgb_backlight(self) -> bool: """Turn on backlight if supported.""" return await self.set_backlight_status(True) async def turn_off_rgb_backlight(self) -> bool: """Turn off backlight if supported.""" return await self.set_backlight_status(False) @deprecated('Use `turn_on_rgb_backlight()` instead.') async def turn_rgb_backlight_on(self) -> bool: """Turn on RGB backlight if supported.""" return await self.set_backlight_status(True) @deprecated('Use `turn_off_rgb_backlight()` instead.') async def turn_rgb_backlight_off(self) -> bool: """Turn off RGB backlight if supported.""" return await self.set_backlight_status(False) async def set_backlight_color(self, red: int, green: int, blue: int) -> bool: """Set the color of the backlight using RGB. Args: red (int): Red value (0-255). green (int): Green value (0-255). blue (int): Blue value (0-255). Returns: bool: True if successful, False otherwise. """ return await self.set_backlight_status(True, red=red, green=green, blue=blue) async def set_brightness(self, brightness: int) -> bool: """Set the brightness of the switch if supported. Args: brightness (int): Brightness value (0-100). Returns: bool: True if successful, False otherwise. """ del brightness if self.supports_dimmable: logger.debug('set_brightness not configured for %s', self.device_name) else: logger.debug('set_brightness not supported for %s', self.device_name) return False @deprecated('Use `turn_on_indicator_light` instead.') async def turn_indicator_light_on(self) -> bool: """Deprecated - use turn_on_indicator_light.""" return await self.toggle_indicator_light(True) @deprecated('Use `turn_off_indicator_light` instead.') async def turn_indicator_light_off(self) -> bool: """Deprecated - use turn_off_indicator_light.""" return await self.toggle_indicator_light(False) webdjoe-pyvesync-eb8cecb/src/pyvesync/base_devices/thermostat_base.py000066400000000000000000000224701507433633000264760ustar00rootroot00000000000000"""Base classes for thermostat devices.""" from __future__ import annotations import logging from typing import TYPE_CHECKING from pyvesync.base_devices.vesyncbasedevice import DeviceState, VeSyncBaseDevice from pyvesync.const import ( ThermostatConst, ThermostatEcoTypes, ThermostatFanModes, ThermostatFanStatus, ThermostatHoldOptions, ThermostatScheduleOrHoldOptions, ThermostatWorkModes, ThermostatWorkStatusCodes, ) if TYPE_CHECKING: from pyvesync import VeSync from pyvesync.device_map import ThermostatMap from pyvesync.models.thermostat_models import ( ThermostatMinorDetails, ThermostatSimpleRoutine, ) from pyvesync.models.vesync_models import ResponseDeviceDetailsModel _LOGGER = logging.getLogger(__name__) class ThermostatState(DeviceState): """VeSync Thermostat State. Args: device (VeSyncThermostat): The thermostat device. details (ResponseDeviceDetailsModel): The thermostat device details. feature_map (ThermostatMap): The thermostat feature map. """ __slots__ = ( 'alert_status', 'battery_level', 'configuration', 'cool_to_temp', 'deadband', 'device_config', 'eco_type', 'fan_mode', 'fan_status', 'filter_life', 'heat_to_temp', 'hold_end_time', 'hold_option', 'humidity', 'lock_status', 'mode', 'routine_running_id', 'routines', 'schedule_or_hold', 'temperature', 'temperature_unit', 'work_mode', 'work_status', ) def __init__( self, device: VeSyncThermostat, details: ResponseDeviceDetailsModel, feature_map: ThermostatMap, ) -> None: """Initialize VeSync Thermostat State.""" super().__init__(device, details, feature_map) self.device: VeSyncThermostat = device self.configuration: ThermostatMinorDetails | None = None self.work_mode: ThermostatWorkModes | None = None self.work_status: ThermostatWorkStatusCodes | None = None self.fan_mode: ThermostatFanModes | None = None self.fan_status: ThermostatFanStatus | None = None self.temperature_unit: str | None = None self.temperature: float | None = None self.humidity: int | None = None self.heat_to_temp: int | None = None self.cool_to_temp: int | None = None self.lock_status: bool = False self.schedule_or_hold: ThermostatScheduleOrHoldOptions | None = None self.hold_end_time: int | None = None self.hold_option: ThermostatHoldOptions | None = None self.deadband: int | None = None self.eco_type: ThermostatEcoTypes | None = None self.alert_status: int | None = None self.routines: list[ThermostatSimpleRoutine] = [] self.routine_running_id: int | None = None self.battery_level: int | None = None self.filter_life: int | None = None @property def is_running(self) -> bool: """Check if the thermostat is running.""" return self.work_status != ThermostatWorkStatusCodes.OFF @property def is_heating(self) -> bool: """Check if the thermostat is heating.""" return self.work_status == ThermostatWorkStatusCodes.HEATING @property def is_cooling(self) -> bool: """Check if the thermostat is cooling.""" return self.work_status == ThermostatWorkStatusCodes.COOLING @property def is_fan_on(self) -> bool: """Check if the fan is on.""" return self.fan_status == ThermostatFanStatus.ON class VeSyncThermostat(VeSyncBaseDevice): """Base class for VeSync Thermostat devices. Args: details (ResponseDeviceDetailsModel): The thermostat device details. manager (VeSync): The VeSync manager instance. feature_map (ThermostatMap): The thermostat feature map. """ __slots__ = ('eco_types', 'fan_modes', 'hold_options', 'supported_work_modes') def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: ThermostatMap, ) -> None: """Initialize VeSync Thermostat.""" super().__init__(details, manager, feature_map) self.state: ThermostatState = ThermostatState(self, details, feature_map) self.fan_modes = feature_map.fan_modes self.supported_work_modes = feature_map.modes self.eco_types = feature_map.eco_types self.hold_options = feature_map.hold_options async def set_mode(self, mode: ThermostatWorkModes) -> bool: """Set the thermostat mode.""" del mode # Unused _LOGGER.debug('set mode not implemented for %s', self.device_type) return False async def turn_off(self) -> bool: """Set mode to off.""" return await self.set_mode(ThermostatWorkModes.OFF) async def set_mode_cool(self) -> bool: """Set mode to cool.""" return await self.set_mode(ThermostatWorkModes.COOL) async def set_mode_heat(self) -> bool: """Set mode to heat.""" return await self.set_mode(ThermostatWorkModes.HEAT) async def set_mode_auto(self) -> bool: """Set mode to auto.""" return await self.set_mode(ThermostatWorkModes.AUTO) async def set_mode_smart_auto(self) -> bool: """Set mode to smart auto.""" return await self.set_mode(ThermostatWorkModes.SMART_AUTO) async def set_mode_emergency_heat(self) -> bool: """Set mode to emergency heat.""" return await self.set_mode(ThermostatWorkModes.EM_HEAT) async def set_fan_mode(self, mode: ThermostatFanModes) -> bool: """Set thermostat fan mode.""" del mode _LOGGER.debug('set fan mode not implemented for %s', self.device_type) return False async def set_fan_ciruclate(self) -> bool: """Set fan circulate.""" return await self.set_fan_mode(ThermostatConst.FanMode.CIRCULATE) async def set_fan_auto(self) -> bool: """Set fan auto.""" return await self.set_fan_mode(ThermostatConst.FanMode.AUTO) async def set_fan_on(self) -> bool: """Set fan on.""" return await self.set_fan_mode(ThermostatConst.FanMode.ON) async def get_configuration(self) -> None: """Retrieve configuration from API.""" _LOGGER.debug('get configuration not implemented for %s', self.device_type) async def set_temp_point(self, temperature: float) -> bool: """Set the temperature point.""" del temperature _LOGGER.debug('set temp point not implemented for %s', self.device_type) return False async def cancel_hold(self) -> bool: """Cancel the scheduled hold.""" _LOGGER.debug('cancel hold not implemented for %s', self.device_type) return False async def set_cool_to_temp(self, temperature: float) -> bool: """Set the cool to temperature. Args: temperature (float): The cool to temperature. Returns: bool: True if successful, False otherwise. """ del temperature _LOGGER.debug('set cool to temp not implemented for %s', self.device_type) return False async def set_heat_to_temp(self, temperature: float) -> bool: """Set the heat to temperature. Args: temperature (float): The heat to temperature. Returns: bool: True if successful, False otherwise. """ del temperature _LOGGER.debug('set heat to temp not implemented for %s', self.device_type) return False async def toggle_lock(self, toggle: bool, pin: int | str | None = None) -> bool: """Toggle the thermostat lock status.""" del toggle, pin _LOGGER.debug('toggle lock not implemented for %s', self.device_type) return False async def turn_on_lock(self, pin: int | str) -> bool: """Turn on the thermostat lock. Args: pin (int | str): The 4-digit PIN code. """ return await self.toggle_lock(True, pin) async def turn_off_lock(self) -> bool: """Turn off the thermostat lock.""" return await self.toggle_lock(False) async def set_eco_type(self, eco_type: ThermostatEcoTypes) -> bool: """Set thermostat eco type. Args: eco_type (ThermostatEcoTypes): The eco type to set, options are found in self.eco_types. Returns: bool: True if successful, False otherwise. """ del eco_type if not self.eco_types: _LOGGER.debug('No eco types available for %s', self.device_type) else: _LOGGER.debug('set_eco_type not configured for %s', self.device_name) return False async def set_eco_first(self) -> bool: """Set eco first.""" return await self.set_eco_type(ThermostatEcoTypes.ECO_FIRST) async def set_eco_second(self) -> bool: """Set eco second.""" return await self.set_eco_type(ThermostatEcoTypes.ECO_SECOND) async def set_eco_comfort_first(self) -> bool: """Set eco comfort.""" return await self.set_eco_type(ThermostatEcoTypes.COMFORT_FIRST) async def set_eco_comfort_second(self) -> bool: """Set eco comfort.""" return await self.set_eco_type(ThermostatEcoTypes.COMFORT_SECOND) webdjoe-pyvesync-eb8cecb/src/pyvesync/base_devices/vesyncbasedevice.py000066400000000000000000000475251507433633000266440ustar00rootroot00000000000000"""Base class for all VeSync devices.""" from __future__ import annotations import inspect import logging from abc import ABC, abstractmethod from datetime import UTC from datetime import datetime as dt from typing import TYPE_CHECKING, Any, Generic, TypeVar import orjson from pyvesync.const import ConnectionStatus, DeviceStatus logger = logging.getLogger(__name__) if TYPE_CHECKING: from pyvesync import VeSync from pyvesync.device_map import DeviceMapTemplate from pyvesync.models.vesync_models import ResponseDeviceDetailsModel from pyvesync.utils.errors import ResponseInfo from pyvesync.utils.helpers import Timer VS_TYPE = TypeVar('VS_TYPE', bound='VeSyncBaseDevice') VS_STATE_T = TypeVar('VS_STATE_T', bound='DeviceState') class VeSyncBaseDevice(ABC, Generic[VS_STATE_T]): """Properties shared across all VeSync devices. Abstract Base Class for all VeSync devices. The device class is used solely for operational methods and static device properties. Parameters: details (ResponseDeviceDetailsModel): Device details from API call. manager (VeSync): Manager object for API calls. feature_map (DeviceMapTemplate): Device configuration map, will be specific subclass of DeviceMapTemplate based on device type. Attributes: state (pyvesync.base_devices.vesyncbasedevice.DeviceState): Device state object Each device has a separate state base class in the base_devices module. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. latest_firm_version (str | None): Latest firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. Methods: set_timer: Set timer for device. get_timer: Get timer for device from API. clear_timer: Clear timer for device from API. set_state: Set device state attribute. get_state: Get device state attribute. update: Update device details. display: Print formatted static device info to stdout. to_json: Print JSON API string to_jsonb: JSON API bytes device details to_dict: Return device information as a dictionary. Note: Device states are found in the `state` attribute in a subclass of DeviceState based on the device type. The `DeviceState` subclass is located in device the base_devices module. The `last_response` attribute is used to store the last response and error information from the API call. See the `pyvesync.errors` module for more information. """ __slots__ = ( '__base_exclusions', '__weakref__', '_exclude_serialization', 'cid', 'config_module', 'connection_type', 'current_firm_version', 'device_image', 'device_name', 'device_region', 'device_type', 'enabled', 'features', 'last_response', 'latest_firm_version', 'mac_id', 'manager', 'pid', 'product_type', 'request_keys', 'state', 'sub_device_no', 'type', 'uuid', ) state: VS_STATE_T def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: DeviceMapTemplate, ) -> None: """Initialize VeSync device base class.""" self._exclude_serialization: list[str] = [] self.enabled: bool = True self.last_response: ResponseInfo | None = None self.manager = manager self.device_name: str = details.deviceName self.device_image: str | None = details.deviceImg self.cid: str = details.cid self.connection_type: str | None = details.connectionType self.device_type: str = details.deviceType self.type: str | None = details.type self.uuid: str | None = details.uuid self.config_module: str = details.configModule self.mac_id: str | None = details.macID self.current_firm_version = details.currentFirmVersion self.latest_firm_version: str | None = None self.device_region: str | None = details.deviceRegion self.pid: str | None = None self.sub_device_no: int | None = details.subDeviceNo # From feature_map self.product_type: str = feature_map.product_type self.features: list[str] = feature_map.features def __eq__(self, other: object) -> bool: """Use device CID and sub-device number to test equality.""" if not isinstance(other, VeSyncBaseDevice): return NotImplemented return bool(other.cid == self.cid and other.sub_device_no == self.sub_device_no) def __hash__(self) -> int: """Use CID and sub-device number to make device hash.""" return hash(self.cid + str(self.sub_device_no)) def __str__(self) -> str: """Use device info for string representation of class.""" return ( f'Device Name: {self.device_name}, ' f'Device Type: {self.device_type}, ' f'SubDevice No.: {self.sub_device_no}' ) def __repr__(self) -> str: """Representation of device details.""" return ( f'DevClass: {self.__class__.__name__}, ' f'Product Type: {self.product_type}, ' f'Name:{self.device_name}, ' f'Device No: {self.sub_device_no}, ' f'CID: {self.cid}' ) @property def is_on(self) -> bool: """Return true if device is on.""" return self.state.device_status in [DeviceStatus.ON, DeviceStatus.RUNNING] @property def firmware_update(self) -> bool: """Return True if firmware update available. This is going to be updated. """ return self.latest_firm_version != self.current_firm_version async def set_timer(self, duration: int, action: str | None = None) -> bool: """Set timer for device. This may not be implemented for all devices. Please open an issue if there is an error. Args: duration (int): Duration in seconds. action (str | None): Action to take when timer expires. Returns: bool: True if successful, False otherwise. """ del duration del action logger.debug('Not implemented - set_timer') return False async def get_timer(self) -> Timer | None: """Get timer for device from API and set the `state.Timer` attribute. This may not be implemented for all devices. Please open an issue if there is an error. Note: This method may not be implemented for all devices. Please open an issue if there is an error. """ logger.debug('Not implemented - get_timer') return None async def clear_timer(self) -> bool: """Clear timer for device from API. This may not be implemented for all devices. Please open an issue if there is an error. Returns: bool: True if successful, False otherwise. """ logger.debug('Not implemented - clear_timer') return False def set_state(self, state_attr: str, stat_value: Any) -> None: # noqa: ANN401 """Set device state attribute.""" setattr(self, state_attr, stat_value) def get_state(self, state_attr: str) -> Any: # noqa: ANN401 """Get device state attribute.""" return getattr(self.state, state_attr) @abstractmethod async def get_details(self) -> None: """Get device details. This method is defined in each device class to contain the logic to pull the device state from the API and update the device's `state` attribute. The `update()` method should be called to update the device state. """ async def update(self) -> None: """Update device details.""" await self.get_details() def display(self, state: bool = True) -> None: """Print formatted static device info to stdout. Args: state (bool): If True, include state in display, defaults to True. Example: ``` Device Name:..................Living Room Lamp Model:........................ESL100 Subdevice No:.................0 Type:.........................wifi CID:..........................1234567890abcdef ``` """ # noinspection SpellCheckingInspection display_list = [ ('Device Name:', self.device_name), ('Product Type: ', self.product_type), ('Model: ', self.device_type), ('Subdevice No: ', str(self.sub_device_no)), ('Type: ', self.type), ('CID: ', self.cid), ('Config Module: ', self.config_module), ('Connection Type: ', self.connection_type), ('Features', self.features), ('Last Response: ', self.last_response), ] if self.uuid is not None: display_list.append(('UUID: ', self.uuid)) for line in display_list: print(f'{line[0]:.<30} {line[1]}') # noqa: T201 if state: self.state.display() def to_json(self, state: bool = True, indent: bool = True) -> str: """Print JSON API string for device details. Args: state (bool): If True, include state in JSON output, defaults to True. indent (bool): If True, indent JSON output, defaults to True. Returns: str: JSON formatted string of device details. """ return self.to_jsonb(state, indent).decode() def to_dict(self, state: bool = True) -> dict[str, Any]: """Return device information as a dictionary. Args: state (bool): If True, include state in dictionary, defaults to True. Returns: dict[str, Any]: Dictionary containing device information. """ device_dict = { 'device_name': self.device_name, 'product_type': self.product_type, 'model': self.device_type, 'subdevice_no': str(self.sub_device_no), 'type': self.type, 'cid': self.cid, 'features:': self.features, 'config_module': self.config_module, 'connection_type': self.connection_type, 'last_response': self.last_response, } state_dict = self.state.to_dict() if state else {} return device_dict | state_dict def to_jsonb(self, state: bool = True, indent: bool = True) -> bytes: """JSON API bytes for device details. Args: state (bool): If True, include state in JSON output, defaults to True. indent (bool): If True, indent JSON output, defaults to True. Returns: bytes: JSON formatted bytes of device details. Example: This is an example without state. ``` { "Device Name": "Living Room Lamp", "Model": "ESL100", "Subdevice No": "0", "Type": "wifi", "CID": "1234567890abcdef" } ``` """ return_dict = self.to_dict(state=state) if indent: return orjson.dumps( return_dict, option=orjson.OPT_INDENT_2 | orjson.OPT_NON_STR_KEYS, ) return orjson.dumps(return_dict, option=orjson.OPT_NON_STR_KEYS) class VeSyncBaseToggleDevice(VeSyncBaseDevice, Generic[VS_STATE_T]): """Base class for VeSync devices that can be toggled on and off. Parameters: details (ResponseDeviceDetailsModel): Device details from API call. manager (VeSync): Manager object for API calls. feature_map (DeviceMapTemplate): Device configuration map, will be specific subclass of DeviceMapTemplate based on device type. Attributes: state (pyvesync.base_devices.vesyncbasedevice.DeviceState): Device state object Each device has a separate state base class in the base_devices module. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. Methods: set_timer: Set timer for device. get_timer: Get timer for device from API. clear_timer: Clear timer for device from API. set_state: Set device state attribute. get_state: Get device state attribute. update: Update device details. display: Print formatted static device info to stdout. to_json: Print JSON API string to_jsonb: JSON API bytes device details to_dict: Return device information as a dictionary. toggle_switch: Toggle device power on or off. turn_on: Turn device on. turn_off: Turn device off. Note: Device states are found in the `state` attribute in a subclass of DeviceState based on the device type. The `DeviceState` subclass is located in device the base_devices module. The `last_response` attribute is used to store the last response and error information from the API call. See the `pyvesync.errors` module for more information. """ __slots__ = () @abstractmethod async def toggle_switch(self, toggle: bool | None = None) -> bool: """Toggle device power on or off. Args: toggle (bool | None): True to turn on, False to turn off, None to toggle. Returns: bool: True if successful, False otherwise. """ async def turn_on(self) -> bool: """Turn device on.""" return await self.toggle_switch(True) async def turn_off(self) -> bool: """Turn device off.""" return await self.toggle_switch(False) class DeviceState: """Base dataclass to hold device state. Args: device (VeSyncBaseDevice): Device object. details (ResponseDeviceDetailsModel): Device details from API call. feature_map (DeviceMapTemplate): Device configuration map, will be specific subclass of DeviceMapTemplate based on device type. Attributes: active_time (int): Active time of device, defaults to None. connection_status (str): Connection status of device. device (VeSyncBaseDevice): Device object. device_status (str): Device status. features (dict): Features of device. last_update_ts (int): Last update timestamp in UTC, defaults to None. Methods: update_ts: Update last update timestamp. to_dict: Dump state to JSON. to_json: Dump state to JSON string. to_jsonb: Dump state to JSON bytes. as_tuple: Convert state to tuple of (name, value) tuples. Note: This cannot be instantiated directly. It should be inherited by the state class of a specific product type. """ __slots__ = ( '__base_exclusions', '_exclude_serialization', 'active_time', 'connection_status', 'device', 'device_status', 'features', 'last_update_ts', 'timer', ) def __init__( self, device: VeSyncBaseDevice, details: ResponseDeviceDetailsModel, feature_map: DeviceMapTemplate, ) -> None: """Initialize device state.""" self.__base_exclusions: list[str] = ['manager', 'device', 'state'] self._exclude_serialization: list[str] = [] self.device = device self.device_status: str = details.deviceStatus or DeviceStatus.UNKNOWN self.connection_status: str = details.connectionStatus or ConnectionStatus.UNKNOWN self.features = feature_map.features self.last_update_ts: int | None = None self.active_time: int | None = None self.timer: Timer | None = None def __str__(self) -> str: """Return device state as string.""" return ( f'{self.device.device_name}, {self.device.product_type}, ' f'Device Status:{self.device_status}, ' f'Connection Status: {self.connection_status}' ) def __repr__(self) -> str: """Return device state as string.""" return ( f'{self.device.device_name}, Device Status:{self.device_status}, ' f'Connection Status: {self.connection_status}' ) def update_ts(self) -> None: """Update last update timestamp as UTC timestamp.""" self.last_update_ts = int(dt.now(tz=UTC).timestamp()) @staticmethod def __predicate(attr: Any) -> bool: # noqa: ANN401 """Check if attribute should be serialized.""" return ( callable(attr) or inspect.ismethod(attr) or inspect.isbuiltin(attr) or inspect.isfunction(attr) or inspect.isroutine(attr) or inspect.isclass(attr) ) def _serialize(self) -> dict[str, Any]: """Get dictionary of state attributes.""" state_dict: dict[str, Any] = {} for attr_name, attr_value in inspect.getmembers( self, lambda a: not self.__predicate(a) ): if attr_name.startswith('_') or attr_name in [ *self.__base_exclusions, *self._exclude_serialization, ]: continue state_dict[attr_name] = attr_value return state_dict def to_json(self, indent: bool = False) -> str: """Dump state to JSON string. Args: indent (bool): If True, indent JSON output, defaults to False. Returns: str: JSON formatted string of device state. """ return self.to_jsonb(indent=indent).decode() def to_jsonb(self, indent: bool = False) -> bytes: """Convert state to JSON bytes.""" if indent: return orjson.dumps( self._serialize(), option=orjson.OPT_NON_STR_KEYS | orjson.OPT_INDENT_2 ) return orjson.dumps(self._serialize(), option=orjson.OPT_NON_STR_KEYS) def to_dict(self) -> dict[str, Any]: """Convert state to dictionary.""" return self._serialize() def as_tuple(self) -> tuple[tuple[str, Any], ...]: """Convert state to tuple of (name, value) tuples.""" return tuple((k, v) for k, v in self._serialize().items()) def display(self) -> None: """Print formatted state to stdout.""" for name, val in self._serialize().items(): print(f'{name:.<30} {val}') # noqa: T201 webdjoe-pyvesync-eb8cecb/src/pyvesync/const.py000066400000000000000000000507501507433633000220260ustar00rootroot00000000000000"""pyvesync library constants. All device states and information are defined by Enums in this module. Attributes: DEFAULT_LANGUAGE (str): Default language for the VeSync app. API_BASE_URL (str): Base URL for the VeSync API. If not specified, a region-specific API URL is automatically selected. API_TIMEOUT (int): Timeout for API requests. USER_AGENT (str): User agent for API requests. DEFAULT_TZ (str): Default timezone for VeSync devices, updated by API after login. DEFAULT_REGION (str): Default region for VeSync devices, updated by API when retrieving devices. APP_VERSION (str): Version of the VeSync app. APP_ID (str): ID of the app. VeSync uses a random 8-letter string, but any non-empty string works. PHONE_BRAND (str): Brand of the phone used to login to the VeSync app. PHONE_OS (str): Operating system of the phone used to login to the VeSync app. MOBILE_ID (str): Unique identifier for the phone used to login to the VeSync app. USER_TYPE (str): User type for the VeSync app - internal app usage. BYPASS_APP_V (str): Bypass app version BYPASS_HEADER_UA (str): Bypass header user agent TERMINAL_ID (str): Unique identifier for new API calls """ from __future__ import annotations import string from enum import Enum, IntEnum, StrEnum from random import choices, randint from types import MappingProxyType from uuid import uuid4 from pyvesync.utils.enum_utils import IntEnumMixin MAX_API_REAUTH_RETRIES = 3 DEFAULT_LANGUAGE = 'en' API_BASE_URL = None # Global URL (non-EU regions): "https://smartapi.vesync.com" # If device is out of reach, the cloud api sends a timeout response after 7 seconds, # using 8 here so there is time enough to catch that message API_BASE_URL_US = 'https://smartapi.vesync.com' API_BASE_URL_EU = 'https://smartapi.vesync.eu' REGION_API_MAP = { 'US': API_BASE_URL_US, 'EU': API_BASE_URL_EU, } NON_EU_COUNTRY_CODES = ['US', 'CA', 'MX', 'JP'] API_TIMEOUT = 8 USER_AGENT = ( 'VeSync/3.2.39 (com.etekcity.vesyncPlatform; build:5; iOS 15.5.0) Alamofire/5.2.1' ) DEFAULT_TZ = 'America/New_York' DEFAULT_REGION = 'US' APP_VERSION = '5.6.60' APP_ID = ''.join(choices(string.ascii_lowercase + string.digits, k=8)) # noqa: S311 PHONE_BRAND = 'pyvesync' PHONE_OS = 'Android' MOBILE_ID = str(randint(1000000000000000, 9999999999999999)) # noqa: S311 USER_TYPE = '1' BYPASS_APP_V = f'VeSync {APP_VERSION}' BYPASS_HEADER_UA = 'okhttp/3.12.1' TERMINAL_ID = '2' + str(uuid4()).replace('-', '') CLIENT_TYPE = 'vesyncApp' STATUS_OK = 200 # Generic Constants KELVIN_MIN = 2700 KELVIN_MAX = 6500 class ProductLines(StrEnum): """High level product line.""" WIFI_LIGHT = 'wifi-light' WIFI_AIR = 'wifi-air' WIFI_KITCHEN = 'wifi-kitchen' SWITCHES = 'Switches' WIFI_SWITCH = 'wifi-switch' THERMOSTAT = 'thermostat' class ProductTypes(StrEnum): """General device types enum.""" OUTLET = 'outlet' BULB = 'bulb' SWITCH = 'switch' PURIFIER = 'purifier' FAN = 'fan' HUMIDIFIER = 'humidifier' AIR_FRYER = 'air fryer' KITCHEN_THERMOMETER = 'kitchen thermometer' THERMOSTAT = 'thermostat' class IntFlag(IntEnum): """DEPRECATED. Integer flag to indicate if a device is not supported. This is used by data models as a default value for feature attributes that are not supported by all devices. The default value is -999. Attributes: NOT_SUPPORTED: Device is not supported, -999 """ NOT_SUPPORTED = -999 def __str__(self) -> str: """Return string representation of IntFlag.""" return str(self.name) class StrFlag(StrEnum): """DEPRECATED. String flag to indicate if a device is not supported. This is used by data models as a default value for feature attributes that are not supported by all devices. The default value is "not_supported". Attributes: NOT_SUPPORTED: Device is not supported, "not_supported" """ NOT_SUPPORTED = 'not_supported' class NightlightStatus(StrEnum): """Nightlight status for VeSync devices. Values can be converted to int and bool. Attributes: ON: Nightlight is on. OFF: Nightlight is off. AUTO: Nightlight is in auto mode. UNKNOWN: Nightlight status is unknown. Usage: ```python NightlightStatus.ON # int(NightlightStatus.ON) # 1 bool(NightlightStatus.ON) # True ``` """ ON = 'on' OFF = 'off' AUTO = 'auto' UNKNOWN = 'unknown' def __bool__(self) -> bool: """Return True if nightlight is on or auto.""" return self in [NightlightStatus.ON, NightlightStatus.AUTO] def __int__(self) -> int: """Return integer representation of the enum.""" if self not in [NightlightStatus.ON, NightlightStatus.OFF]: raise ValueError('Only ON and OFF are valid values for int conversion') return int(self == NightlightStatus.ON) class DeviceStatus(StrEnum): """VeSync device status enum. In addition to converting to int and bool values, this enum can be used to convert from bool and int values to corresponding string values. Attributes: ON: Device is on. OFF: Device is off. PAUSED: Device is paused. STANDBY: Device is in standby mode. IDLE: Device is idle. RUNNING: Device is running. UNKNOWN: Device status is unknown. Usage: ```python DeviceStatus.ON # bool(DeviceStatus.ON) # True int(DeviceStatus.ON) # 1 DeviceStatus.from_int(1) # 'on' DeviceStatus.from_bool(True) # 'on' ``` """ ON = 'on' OFF = 'off' PAUSED = 'paused' STANDBY = 'standby' IDLE = 'idle' RUNNING = 'running' UNKNOWN = 'unknown' def __bool__(self) -> bool: """Return True if device is on or running.""" return self in [DeviceStatus.ON, DeviceStatus.RUNNING] def __int__(self) -> int: """Return integer representation of the enum. 1 is ON and RUNNING, 0 is OFF, PAUSED, STANDBY, IDLE. -1 is UNKNOWN. """ match self: case DeviceStatus.ON | DeviceStatus.RUNNING: return 1 case ( DeviceStatus.OFF | DeviceStatus.PAUSED | DeviceStatus.STANDBY | DeviceStatus.IDLE ): return 0 return -1 @classmethod def from_int(cls, value: int | None) -> str: """Convert integer value to corresponding string. If value is 1, return ON and if 0, return OFF. If value is -999, return NOT_SUPPORTED. """ if value == 1: return cls.ON if value == 0: return cls.OFF if value == IntFlag.NOT_SUPPORTED: return StrFlag.NOT_SUPPORTED return cls.UNKNOWN @classmethod def from_bool(cls, value: bool) -> DeviceStatus: """Convert boolean value to corresponding string.""" return cls.ON if value is True else cls.OFF class ConnectionStatus(StrEnum): """VeSync device connection status enum. Corresponding boolean value is True if device is online. Attributes: ONLINE: Device is online. OFFLINE: Device is offline. UNKNOWN: Device connection status is unknown. Methods: from_bool(value: bool | None) -> ConnectionStatus: Convert boolean value to corresponding string. Usage: ```python ConnectionStatus.ONLINE # bool(ConnectionStatus.ONLINE) # True ConnectionStatus.ONLINE == ConnectionStatus.ONLINE # True ``` """ ONLINE = 'online' OFFLINE = 'offline' UNKNOWN = 'unknown' def __bool__(self) -> bool: """Return True if device is online.""" return self == ConnectionStatus.ONLINE @classmethod def from_bool(cls, value: bool | None) -> ConnectionStatus: """Convert boolean value to corresponding string. Returns ConnectionStatus.ONLINE if True, else ConnectionStatus.OFFLINE. """ return cls.ONLINE if value else cls.OFFLINE class NightlightModes(StrEnum): """Nightlight modes. Attributes: ON: Nightlight is on. OFF: Nightlight is off. DIM: Nightlight is dimmed. AUTO: Nightlight is in auto mode. UNKNOWN: Nightlight status is unknown. """ ON = 'on' OFF = 'off' DIM = 'dim' AUTO = 'auto' UNKNOWN = 'unknown' def __bool__(self) -> bool: """Return True if nightlight is on or auto. Off and unknown are False, all other True. """ return self in [NightlightModes.ON, NightlightModes.AUTO, NightlightModes.DIM] class ColorMode(StrEnum): """VeSync bulb color modes. Attributes: RGB: RGB color mode. HSV: HSV color mode. WHITE: White color mode. COLOR: Color mode. """ RGB = 'rgb' HSV = 'hsv' WHITE = 'white' COLOR = 'color' # Purifier Constants class AirQualityLevel(Enum): """Representation of air quality levels as string and integers. Attributes: EXCELLENT: Air quality is excellent. GOOD: Air quality is good. MODERATE: Air quality is moderate. POOR: Air quality is poor. UNKNOWN: Air quality is unknown. Methods: from_string(value: str | None) -> AirQualityLevel: Convert string value to corresponding integer. from_int(value: int | None) -> AirQualityLevel: Convert integer value to corresponding string. Note: Alias for "very good" is "excellent". Alias for "bad" is "poor". Usage: ```python AirQualityLevels.EXCELLENT # AirQualityLevels.from_string("excellent") # 1 AirQualityLevels.from_int(1) # "excellent" int(AirQualityLevels.EXCELLENT) # 1 str(AirQualityLevels.EXCELLENT) # "excellent" from_string("good") # from_int(2) # "good" ``` """ EXCELLENT = 1 GOOD = 2 MODERATE = 3 POOR = 4 UNKNOWN = -1 def __int__(self) -> int: """Return integer representation of the enum.""" return self.value def __str__(self) -> str: """Return string representation of the enum.""" return self.name.lower() @classmethod def from_string(cls, value: str | None) -> AirQualityLevel: """Convert string value to corresponding integer. Get enum from string value to normalize different values of the same format. Note: Values are excellent, good, moderate and poor. Aliases are: very good for excellent and bad for poor. Unknown is returned if value is None or not in the list. """ _string_to_enum = MappingProxyType( { 'excellent': cls.EXCELLENT, 'very good': cls.EXCELLENT, # Alias 'good': cls.GOOD, 'moderate': cls.MODERATE, 'poor': cls.POOR, 'bad': cls.POOR, # Alias 'unknown': cls.UNKNOWN, } ) if isinstance(value, str) and value.lower() in _string_to_enum: return AirQualityLevel(_string_to_enum[value.lower()]) return cls.UNKNOWN @classmethod def from_int(cls, value: int | None) -> AirQualityLevel: """Convert integer value to corresponding string.""" if value in [itm.value for itm in cls]: return cls(value) return cls.UNKNOWN class PurifierAutoPreference(StrEnum): """Preference Levels for Purifier Auto Mode. Attributes: DEFAULT: Default preference level. EFFICIENT: Efficient preference level. QUIET: Quiet preference level. UNKNOWN: Unknown preference level. """ DEFAULT = 'default' EFFICIENT = 'efficient' QUIET = 'quiet' UNKNOWN = 'unknown' # Fan Constants class FanSleepPreference(StrEnum): """Sleep mode preferences for VeSync fans. Attributes: DEFAULT: Default sleep mode. ADVANCED: Advanced sleep mode. TURBO: Turbo sleep mode. EFFICIENT: Efficient sleep mode. QUIET: Quiet sleep mode. UNKNOWN: Unknown sleep mode. """ DEFAULT = 'default' ADVANCED = 'advanced' TURBO = 'turbo' EFFICIENT = 'efficient' QUIET = 'quiet' TEMP_SENSE = 'tempSense' KIDS = 'kids' UNKNOWN = 'unknown' # Device Features class Features(StrEnum): """Base Class for Features Enum to appease typing.""" # Device Features class HumidifierFeatures(Features): """VeSync humidifier features. Attributes: ONOFF: Device on/off status. CHILD_LOCK: Child lock status. NIGHTLIGHT: Nightlight status. WATER_LEVEL: Water level status. WARM_MIST: Warm mist status. AUTO_STOP: Auto stop when target humidity is reached. Different from auto, which adjusts fan level to maintain humidity. """ ONOFF = 'onoff' CHILD_LOCK = 'child_lock' NIGHTLIGHT = 'night_light' WATER_LEVEL = 'water_level' WARM_MIST = 'warm_mist' AUTO_STOP = 'auto_stop' NIGHTLIGHT_BRIGHTNESS = 'nightlight_brightness' DRYING_MODE = 'drying_mode' class PurifierFeatures(Features): """VeSync air purifier features. Attributes: CHILD_LOCK: Child lock status. NIGHTLIGHT: Nightlight status. AIR_QUALITY: Air quality status. VENT_ANGLE: Vent angle status. LIGHT_DETECT: Light detection status. PM25: PM2.5 level status. PM10: PM10 level status. PM1: PM1 level status. AQPERCENT: Air quality percentage status. RESET_FILTER: Reset filter status. """ RESET_FILTER = 'reset_filter' CHILD_LOCK = 'child_lock' NIGHTLIGHT = 'night_light' AIR_QUALITY = 'air_quality' VENT_ANGLE = 'fan_rotate' LIGHT_DETECT = 'light_detect' PM25 = 'pm25' PM10 = 'pm10' PM1 = 'pm1' AQPERCENT = 'aq_percent' class PurifierStringLevels(Features): """String levels for Air Purifier fan speed. Attributes: LOW: Low fan speed. MEDIUM: Medium fan speed. HIGH: High fan speed. """ LOW = 'low' MEDIUM = 'medium' HIGH = 'high' class BulbFeatures(Features): """VeSync bulb features. Attributes: ONOFF: Device on/off status. DIMMABLE: Dimmable status. COLOR_TEMP: Color temperature status. MULTICOLOR: Multicolor status. """ ONOFF = 'onoff' DIMMABLE = 'dimmable' COLOR_TEMP = 'color_temp' MULTICOLOR = 'multicolor' class OutletFeatures(Features): """VeSync outlet features. Attributes: ONOFF: Device on/off status. ENERGY_MONITOR: Energy monitor status. NIGHTLIGHT: Nightlight status. """ ONOFF = 'onoff' ENERGY_MONITOR = 'energy_monitor' NIGHTLIGHT = 'nightlight' class SwitchFeatures(Features): """VeSync switch features. Attributes: ONOFF: Device on/off status. DIMMABLE: Dimmable status. INDICATOR_LIGHT: Indicator light status. BACKLIGHT: Backlight status. BACKLIGHT_RGB: RGB backlight status. """ ONOFF = 'onoff' DIMMABLE = 'dimmable' INDICATOR_LIGHT = 'indicator_light' BACKLIGHT = 'backlight' BACKLIGHT_RGB = 'backlight_rgb' class FanFeatures(Features): """VeSync fan features.""" OSCILLATION = 'oscillation' SOUND = 'sound' DISPLAYING_TYPE = 'displaying_type' # Unknown functionality # Modes class PurifierModes(Features): """VeSync air purifier modes. Attributes: AUTO: Auto mode. MANUAL: Manual mode. SLEEP: Sleep mode. TURBO: Turbo mode. PET: Pet mode. UNKNOWN: Unknown mode. """ AUTO = 'auto' MANUAL = 'manual' SLEEP = 'sleep' TURBO = 'turbo' PET = 'pet' UNKNOWN = 'unknown' class HumidifierModes(Features): """VeSync humidifier modes. Attributes: AUTO: Auto mode. MANUAL: Manual mode. HUMIDITY: Humidity mode. SLEEP: Sleep mode. TURBO: Turbo mode. PET: Pet mode. UNKNOWN: Unknown mode. AUTOPRO: AutoPro mode. """ AUTO = 'auto' MANUAL = 'manual' HUMIDITY = 'humidity' SLEEP = 'sleep' TURBO = 'turbo' PET = 'pet' UNKNOWN = 'unknown' AUTOPRO = 'autopro' class FanModes(StrEnum): """VeSync fan modes. Attributes: AUTO: Auto mode. NORMAL: Normal mode. MANUAL: Manual mode. SLEEP: Sleep mode. TURBO: Turbo mode. PET: Pet mode. UNKNOWN: Unknown mode. ADVANCED_SLEEP: Advanced sleep mode. """ AUTO = 'auto' NORMAL = 'normal' MANUAL = 'normal' SLEEP = 'advancedSleep' TURBO = 'turbo' PET = 'pet' UNKNOWN = 'unknown' ADVANCED_SLEEP = 'advancedSleep' # Air Fryer Constants AIRFRYER_PID_MAP = { 'WiFi_SKA_AirFryer137_US': 'wnxwqs76gknqyzjn', 'WiFi_SKA_AirFryer158_US': '2cl8hmafsthl65bd', 'WiFi_AirFryer_CS158-AF_EU': '8t8op7pcvzlsbosm', } """PID's for VeSync Air Fryers based on ConfigModule.""" # Thermostat Constants class ThermostatWorkModes(IntEnum): """Working modes for VeSync Aura thermostats. Based on the VeSync app and API values. Attributes: OFF: Thermostat is off (0). HEAT: Thermostat is in heating mode (1). COOL: Thermostat is in cooling mode (2). AUTO: Thermostat is in auto mode (3). EM_HEAT: Thermostat is in emergency heating mode (4). SMART_AUTO: Thermostat is in smart auto mode (5). """ OFF = 0 HEAT = 1 COOL = 2 AUTO = 3 EM_HEAT = 4 SMART_AUTO = 5 class ThermostatFanModes(IntEnum): """Fan modes for VeSync Aura thermostats. Based on the VeSync app and API values. Attributes: AUTO: Fan is in auto mode (1). ON: Fan is on (2). CIRCULATE: Fan is in circulate mode (3). """ AUTO = 1 ON = 2 CIRCULATE = 3 class ThermostatHoldOptions(IntEnumMixin): """Hold options for VeSync Aura thermostats. Attributes: UNTIL_NEXT_SCHEDULED_ITEM: Hold until next scheduled item (2). TWO_HOURS: Hold for two hours (3). FOUR_HOURS: Hold for four hours (4). PERMANENTLY: Hold permanently (5). """ UNTIL_NEXT_SCHEDULED_ITEM = 2 TWO_HOURS = 3 FOUR_HOURS = 4 PERMANENTLY = 5 class ThermostatHoldStatus(IntEnumMixin): """Set the hold status of the thermostat. Attributes: SET: Set the hold status (1). CANCEL: Cancel the hold status (0). """ SET = 1 CANCEL = 0 class ThermostatScheduleOrHoldOptions(IntEnumMixin): """Schedule or hold options for VeSync Aura thermostats.""" NOT_SCHEDULE_NOT_HOLD = 0 ON_SCHEDULE = 1 ON_HOLD = 2 VACATION = 3 class ThermostatEcoTypes(IntEnumMixin): """Eco types for VeSync Aura thermostats.""" COMFORT_SECOND = 1 COMFORT_FIRST = 2 BALANCE = 3 ECO_FIRST = 4 ECO_SECOND = 5 class ThermostatRoutineTypes(IntEnumMixin): """Routine types for VeSync Aura thermostats.""" HOME = 2 AWAY = 1 SLEEP = 3 CUSTOM = 4 class ThermostatAlarmCodes(IntEnumMixin): """Alarm codes for VeSync Aura thermostats.""" HEAT_COOL = 100 ABOVE_SAFE_TEMP = 102 HEAT_RUNNING_TIME_TOO_LONG = 104 class ThermostatReminderCodes(IntEnumMixin): """Reminder codes for VeSync Aura thermostats.""" FILTER = 150 HVAC = 151 class ThermostatWorkStatusCodes(IntEnumMixin): """Work status codes for VeSync Aura thermostats.""" OFF = 0 HEATING = 1 COOLING = 2 EM_HEATING = 3 class ThermostatFanStatus(IntEnumMixin): """Fan Status Enum for Aura Thermostats.""" OFF = 0 ON = 1 class ThermostatConst: """Constants for VeSync Aura thermostats.""" ReminderCode = ThermostatReminderCodes AlarmCode = ThermostatAlarmCodes WorkMode = ThermostatWorkModes FanStatus = ThermostatFanStatus FanMode = ThermostatFanModes HoldOption = ThermostatHoldOptions EcoType = ThermostatEcoTypes RoutineType = ThermostatRoutineTypes ScheduleOrHoldOption = ThermostatScheduleOrHoldOptions WorkStatus = ThermostatWorkStatusCodes # ------------------- AIR FRYER CONST ------------------ # CUSTOM_RECIPE_ID = 1 CUSTOM_RECIPE_TYPE = 3 CUSTOM_RECIPE_NAME = 'Manual Cook' CUSTOM_COOK_MODE = 'custom' webdjoe-pyvesync-eb8cecb/src/pyvesync/device_container.py000066400000000000000000000312751507433633000242020ustar00rootroot00000000000000"""Module to contain VeSync device instances. Attributes: DeviceContainerInstance (DeviceContainer): Singleton instance of the DeviceContainer class. This is imported by the `vesync` module. Classes: DeviceContainer: Container for VeSync device instances. This class should not be instantiated directly. Use the `DeviceContainerInstance` instead. _DeviceContainerBase: Base class for VeSync device container. Inherits from `MutableSet`. """ from __future__ import annotations import logging import re from collections.abc import Iterator, MutableSet, Sequence from typing import TYPE_CHECKING, TypeVar from pyvesync.base_devices.bulb_base import VeSyncBulb from pyvesync.base_devices.fan_base import VeSyncFanBase from pyvesync.base_devices.fryer_base import VeSyncFryer from pyvesync.base_devices.humidifier_base import VeSyncHumidifier from pyvesync.base_devices.outlet_base import VeSyncOutlet from pyvesync.base_devices.purifier_base import VeSyncPurifier from pyvesync.base_devices.switch_base import VeSyncSwitch from pyvesync.base_devices.thermostat_base import VeSyncThermostat from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice from pyvesync.const import ProductTypes from pyvesync.device_map import get_device_config if TYPE_CHECKING: from pyvesync import VeSync from pyvesync.models.vesync_models import ( ResponseDeviceDetailsModel, ResponseDeviceListModel, ) logger = logging.getLogger(__name__) T = TypeVar('T') def _clean_string(string: str) -> str: """Clean a string by removing non alphanumeric characters and making lowercase.""" return re.sub(r'[^a0zA-Z0-9]', '', string) class _DeviceContainerBase(MutableSet[VeSyncBaseDevice]): """Base class for VeSync device container. Inherits from `MutableSet` and defines the core MutableSet methods. """ __slots__ = ('__weakref__', '_data') def __init__( self, sequence: Sequence[VeSyncBaseDevice] | None = None, /, ) -> None: """Initialize the DeviceContainer class.""" self._data: set[VeSyncBaseDevice] = set() if isinstance(sequence, Sequence): self._data.update(sequence) def __iter__(self) -> Iterator[VeSyncBaseDevice]: """Iterate over the container.""" return iter(self._data) def __len__(self) -> int: """Return the length of the container.""" return len(self._data) def add(self, value: VeSyncBaseDevice) -> None: """Add a device to the container.""" if value in self._data: logger.debug('Device already exists') return self._data.add(value) def remove(self, value: VeSyncBaseDevice) -> None: """Remove a device from the container.""" self._data.remove(value) def clear(self) -> None: """Clear the container.""" self._data.clear() def __contains__(self, value: object) -> bool: """Check if a device is in the container.""" return value in self._data class DeviceContainer(_DeviceContainerBase): """Container for VeSync device instances. /// admonition | Warning type: warning This class should not be instantiated directly. Use the `DeviceContainerInstance` instead. /// The `DeviceContainer` class is a container for VeSync device instances that inherits behavior from `MutableSet`. The container contains all VeSync devices and is used to store and manage device instances. The container is a singleton and is instantiated directly by the `DeviceContainerInstance` in the `device_container` module and imported as needed. Use the [`add_new_devices`][pyvesync.device_container.DeviceContainer.add_new_devices] class method to add devices from the device list model API response and `remove_stale_devices` to remove stale devices from the device list API response model. The device list response model is built in the [VeSync.get_devices()][pyvesync.vesync.VeSync.get_devices] method. Args: sequence (Sequence[VeSyncBaseDevice] | None): A sequence of device instances to initialize the container with. Typically this is not used directly, defaults to None. Attributes: _data (set[VeSyncBaseDevice]): The mutable set of devices in the container. """ __slots__ = () def __init__( self, sequence: Sequence[VeSyncBaseDevice] | None = None, /, ) -> None: """Initialize the DeviceContainer class.""" super().__init__(sequence) def _build_device_instance( self, device: ResponseDeviceDetailsModel, manager: VeSync ) -> VeSyncBaseDevice | None: """Create a device from a single device model from the device list. Args: device (ResponseDeviceDetailsModel): The device details model from the device list response model. manager (VeSync): The VeSync instance to pass to the device instance Returns: VeSyncBaseDevice: The device instance created from the device list response model. Raises: VeSyncAPIResponseError: If the model is not an instance of `ResponseDeviceDetailsModel`. """ device_features = get_device_config(device.deviceType) if device_features is None: logger.debug('Device type %s not found in device map', device.deviceType) return None dev_class = device_features.class_name dev_module = device_features.module device.productType = device_features.product_type # Import via string to avoid circular imports cls = getattr(dev_module, dev_class) return cls(device, manager, device_features) def add_device_from_model( self, device: ResponseDeviceDetailsModel, manager: VeSync ) -> None: """Add a single device from the device list response model. Args: device (ResponseDeviceDetailsModel): The device details model from the device list response model. manager (VeSync): The VeSync instance to pass to the device instance Raises: VeSyncAPIResponseError: If the model is not an instance of `ResponseDeviceDetailsModel`. """ device_obj = self._build_device_instance(device, manager) if device_obj is not None: self.add(device_obj) def device_exists(self, cid: str, sub_device_no: int | None = None) -> bool: """Check if a device with the given cid & sub_dev_no exists. Args: cid (str): The cid of the device to check. sub_device_no (int): The sub_device_no of the device to check, defaults to 0 for most devices. Returns: bool: True if the device exists, False otherwise. """ new_hash = hash(cid + str(sub_device_no)) return any(new_hash == hash(dev) for dev in self._data) def get_by_name(self, name: str, fuzzy: bool = False) -> VeSyncBaseDevice | None: """Forgiving method to get a device by name. Args: name (str): The name of the device to get. fuzzy (bool): Use a fuzzy match to find the device. Defaults to False. Returns: VeSyncBaseDevice | None: The device instance if found, None otherwise. Note: Fuzzy matching removes all non-alphanumeric characters and makes the string lowercase. If there are multiple devices with the same name, the first one found will be returned (a set is unordered). """ for device in self._data: if (fuzzy and _clean_string(device.device_name) == _clean_string(name)) or ( device.device_name == name ): return device return None def remove_by_cid(self, cid: str) -> bool: """Remove a device by cid. Args: cid (str): The cid of the device to remove. Returns: bool : True if the device was removed, False otherwise. """ device_found: VeSyncBaseDevice | None = None for device in self._data: if device.cid == cid: device_found = device break if device_found is not None: self.remove(device_found) return True return False def discard(self, value: VeSyncBaseDevice) -> None: """Discard a device from the container. Args: value (VeSyncBaseDevice): The device to discard. """ return self._data.discard(value) def remove_stale_devices(self, device_list_result: ResponseDeviceListModel) -> None: """Remove devices that are not in the provided list. Args: device_list_result (ResponseDeviceListModel): The device list response model from the VeSync API. This is generated by the `VeSync.get_devices()` method. """ device_list = device_list_result.result.list new_hashes = [ hash(device.cid + str(device.subDeviceNo)) for device in device_list ] remove_cids = [] for device in self._data: if hash(device) not in new_hashes: logger.debug('Removing stale device %s', device.device_name) remove_cids.append(device.cid) for cid in remove_cids: self.remove_by_cid(cid) def add_new_devices( self, device_list_result: ResponseDeviceListModel, manager: VeSync ) -> None: """Add new devices to the container. Args: device_list_result (ResponseDeviceListModel): The device list response model from the VeSync API. This is generated by the `VeSync.get_devices()` method. manager (VeSync): The VeSync instance to pass to the device instance """ device_list = device_list_result.result.list for device in device_list: if self.device_exists(device.cid, device.subDeviceNo) not in self._data: self.add_device_from_model(device, manager) @property def outlets(self) -> list[VeSyncOutlet]: """Return a list of devices that are outlets.""" return [ device for device in self if isinstance(device, VeSyncOutlet) and device.product_type == ProductTypes.OUTLET ] @property def switches(self) -> list[VeSyncSwitch]: """Return a list of devices that are switches.""" return [ device for device in self if ( isinstance(device, VeSyncSwitch) and device.product_type == ProductTypes.SWITCH ) ] @property def bulbs(self) -> list[VeSyncBulb]: """Return a list of devices that are lights.""" return [ device for device in self if isinstance(device, VeSyncBulb) and (device.product_type == ProductTypes.BULB) ] @property def air_purifiers(self) -> list[VeSyncPurifier]: """Return a list of devices that are air purifiers.""" return [ device for device in self if isinstance(device, VeSyncPurifier) and device.product_type == ProductTypes.PURIFIER ] @property def fans(self) -> list[VeSyncFanBase]: """Return a list of devices that are fans.""" return [ device for device in self if isinstance(device, VeSyncFanBase) and device.product_type == ProductTypes.FAN ] @property def humidifiers(self) -> list[VeSyncHumidifier]: """Return a list of devices that are humidifiers.""" return [ device for device in self if isinstance(device, VeSyncHumidifier) and device.product_type == ProductTypes.HUMIDIFIER ] @property def air_fryers(self) -> list[VeSyncFryer]: """Return a list of devices that are air fryers.""" return [ device for device in self if isinstance(device, VeSyncFryer) and device.product_type == ProductTypes.AIR_FRYER ] @property def thermostats(self) -> list[VeSyncThermostat]: """Return a list of devices that are thermostats.""" return [ device for device in self if isinstance(device, VeSyncThermostat) and device.product_type == ProductTypes.THERMOSTAT ] DeviceContainerInstance = DeviceContainer() """Singleton instance of the DeviceContainer class. This attribute should be imported by the `vesync` module and not the `DeviceContainer` class directly. """ webdjoe-pyvesync-eb8cecb/src/pyvesync/device_map.py000066400000000000000000001176771507433633000230100ustar00rootroot00000000000000"""Device and module mappings for VeSync devices. **To add a new device type to existing module: Add the device_type to the end of the existing dev_types list.** This module contains mappings for VeSync devices to their respective classes. The mappings are used to create instances of the appropriate device class based on the device type and define features and modes. The device type is taken from the `deviceType` field in the device list API. The AirFryerMap, OutletMap, SwitchMap, BulbMap, FanMap, HumidifierMap, PurifierMap and ThermostatMap dataclasses are used to define the mappings for each product type with the assocated module, class, features and other device specific configuration. The [`get_device_config`][pyvesync.device_map.get_device_config] function is used to get the device map object from the device type to instantiate the appropriate class. The individual `get_` functions are used to get the device details for the specific to the device type. Both functions return the same model, the individual product type functions are used to satisfy type checking in the individual devices. Attributes: outlet_modules: list[OutletMap]: List of Outlet device mappings. switch_modules: list[SwitchMap]: List of Switch device mappings. bulb_modules: list[BulbMap]: List of Bulb device mappings. fan_modules: list[FanMap]: List of Fan device mappings. purifier_modules: list[PurifierMap]: List of Purifier device mappings. humidifier_modules: list[HumidifierMap]: List of Humidifier device mappings. air_fryer_modules: list[AirFryerMap]: List of Air Fryer device mappings. thermostat_modules: list[ThermostatMap]: List of Thermostat device mappings. Classes: ProductTypes: Enum: General device types enum. DeviceMapTemplate: Template for DeviceModules mapping. OutletMap: Template for Outlet device mapping. SwitchMap: Template for Switch device mapping. BulbMap: dataclass: Template for Bulb device mapping. FanMap: dataclass: Template for Fan device mapping. HumidifierMap: dataclass: Template for Humidifier device mapping. PurifierMap: dataclass: Template for Purifier device mapping. AirFryerMap: dataclass: Template for Air Fryer device mapping. ThermostatMap: dataclass: Template for Thermostat device mapping. Functions: get_device_config: Get the device map object from the device type. get_outlet: Get outlet device config, returning OutletMap object. get_switch: Get switch device config, returning SwitchMap object. get_bulb: Get the bulb device config, returning BulbMap object. get_fan: Get the fan device config, returning the FanMap object. get_humidifier: Get the humidfier config, returning the HumidifierMap object. get_purifier: Get the purifier config, returning the PurifierMap object. get_air_fryer: Get the Air Fryer config, returning the AirFryerMap object. get_thermostat: Get the thermostat config, returning the ThermostatMap object. Note: To add devices, add the device mapping to the appropriate `_modules` list, ensuring all required fields are present based on the `Map` fields. """ from __future__ import annotations from dataclasses import dataclass, field from itertools import chain from types import ModuleType from typing import Union from pyvesync.const import ( BulbFeatures, ColorMode, FanFeatures, FanModes, FanSleepPreference, HumidifierFeatures, HumidifierModes, NightlightModes, OutletFeatures, ProductLines, ProductTypes, PurifierAutoPreference, PurifierFeatures, PurifierModes, SwitchFeatures, ThermostatEcoTypes, ThermostatFanModes, ThermostatHoldOptions, ThermostatRoutineTypes, ThermostatWorkModes, ) from pyvesync.devices import ( vesyncbulb, vesyncfan, vesynchumidifier, vesynckitchen, vesyncoutlet, vesyncpurifier, vesyncswitch, vesyncthermostat, ) T_MAPS = Union[ # noqa: UP007, RUF100 list['OutletMap'], list['SwitchMap'], list['BulbMap'], list['FanMap'], list['HumidifierMap'], list['PurifierMap'], list['AirFryerMap'], list['ThermostatMap'], ] @dataclass(kw_only=True) class DeviceMapTemplate: """Template for DeviceModules mapping. Attributes: dev_types (list[str]): List of device types to match from API. class_name (str): Class name of the device. product_type (str): Product type of the device. module (ModuleType): Module for the device. setup_entry (str): Setup entry for the device, if unknown use the device_type base without region model_display (str): Display name of the model. model_name (str): Name of the model. device_alias (str | None): Alias for the device, if any. features (list[str]): List of features for the device. """ dev_types: list[str] class_name: str product_type: str product_line: str module: ModuleType setup_entry: str model_display: str model_name: str device_alias: str | None = None features: list[str] = field(default_factory=list) @dataclass(kw_only=True) class OutletMap(DeviceMapTemplate): """Template for DeviceModules mapping. Attributes: dev_types (list[str]): List of device types to match from API. class_name (str): Class name of the device. product_type (str): Product type of the device - ProductTypes.OUTLET module (ModuleType): Module for the device. setup_entry (str): Setup entry for the device, if unknown use the device_type base without region model_display (str): Display name of the model. model_name (str): Name of the model. device_alias (str | None): Alias for the device, if any. features (list[str]): List of features for the device. product_type (str): Product type of the device. module (ModuleType): Module for the device. nightlight_modes (list[str]): List of nightlight modes for the device. """ product_line: str = ProductLines.WIFI_LIGHT product_type: str = ProductTypes.OUTLET module: ModuleType = vesyncoutlet nightlight_modes: list[NightlightModes] = field(default_factory=list) @dataclass(kw_only=True) class SwitchMap(DeviceMapTemplate): """Template for DeviceModules mapping. Attributes: dev_types (list[str]): List of device types to match from API. class_name (str): Class name of the device. product_type (str): Product type of the device - ProductTypes.SWITCH module (ModuleType): Module for the device. setup_entry (str): Setup entry for the device, if unknown use the device_type base without region model_display (str): Display name of the model. model_name (str): Name of the model. device_alias (str | None): Alias for the device, if any. features (list[str]): List of features for the device. product_type (str): Product type of the device. module (ModuleType): Module for the device. """ product_line: str = ProductLines.SWITCHES product_type: str = ProductTypes.SWITCH module: ModuleType = vesyncswitch @dataclass(kw_only=True) class BulbMap(DeviceMapTemplate): """Template for DeviceModules mapping. Attributes: dev_types (list[str]): List of device types to match from API. class_name (str): Class name of the device. product_type (str): Product type of the device - ProductTypes.BULB module (ModuleType): Module for the device. setup_entry (str): Setup entry for the device, if unknown use the device_type base without region model_display (str): Display name of the model. model_name (str): Name of the model. device_alias (str | None): Alias for the device, if any. features (list[str]): List of features for the device. color_model (str | None): Color model for the device. color_modes (list[str]): List of color modes for the device. """ product_line: str = ProductLines.WIFI_LIGHT color_model: str | None = None product_type: str = ProductTypes.BULB module: ModuleType = vesyncbulb color_modes: list[str] = field(default_factory=list) @dataclass(kw_only=True) class FanMap(DeviceMapTemplate): """Template for DeviceModules mapping. Attributes: dev_types (list[str]): List of device types to match from API. class_name (str): Class name of the device. product_type (str): Product type of the device - ProductTypes.FAN module (ModuleType): Module for the device. setup_entry (str): Setup entry for the device, if unknown use the device_type base without region model_display (str): Display name of the model. model_name (str): Name of the model. device_alias (str | None): Alias for the device, if any. features (list[str]): List of features for the device. fan_levels (list[int]): List of fan levels for the device. modes (list[str]): List of modes for the device. sleep_preferences (list[str]): List of sleep preferences for the device. set_mode_method (str): Method to set the mode for the device. """ product_line: str = ProductLines.WIFI_AIR product_type: str = ProductTypes.FAN module: ModuleType = vesyncfan fan_levels: list[int] = field(default_factory=list) modes: list[str] = field(default_factory=list) sleep_preferences: list[str] = field(default_factory=list) set_mode_method: str = '' @dataclass(kw_only=True) class HumidifierMap(DeviceMapTemplate): """Template for DeviceModules mapping. Attributes: dev_types (list[str]): List of device types to match from API. class_name (str): Class name of the device. product_type (str): Product type of the device - ProductTypes.HUMIDIFIER module (ModuleType): Module for the device. setup_entry (str): Setup entry for the device, if unknown use the device_type base without region model_display (str): Display name of the model. model_name (str): Name of the model. device_alias (str | None): Alias for the device, if any. features (list[str]): List of features for the device. mist_modes (dict[str, str]): Dictionary of mist modes for the device. mist_levels (list[int | str]): List of mist levels for the device. target_minmax (tuple[int, int]): Minimum and maximum target humidity levels. warm_mist_levels (list[int | str]): List of warm mist levels for the device. """ product_line: str = ProductLines.WIFI_AIR mist_modes: dict[str, str] = field(default_factory=dict) mist_levels: list[int | str] = field(default_factory=list) product_type: str = ProductTypes.HUMIDIFIER module: ModuleType = vesynchumidifier target_minmax: tuple[int, int] = (30, 80) warm_mist_levels: list[int | str] = field(default_factory=list) @dataclass(kw_only=True) class PurifierMap(DeviceMapTemplate): """Template for DeviceModules mapping. Attributes: dev_types (list[str]): List of device types to match from API. class_name (str): Class name of the device. product_type (str): Product type of the device - ProductTypes.PURIFIER module (ModuleType): Module for the device. setup_entry (str): Setup entry for the device, if unknown use the device_type base without region model_display (str): Display name of the model. model_name (str): Name of the model. device_alias (str | None): Alias for the device, if any. features (list[str]): List of features for the device. fan_levels (list[int]): List of fan levels for the device. modes (list[str]): List of modes for the device. nightlight_modes (list[str]): List of nightlight modes for the device. auto_preferences (list[str]): List of auto preferences for the device. """ product_line: str = ProductLines.WIFI_AIR product_type: str = ProductTypes.PURIFIER module: ModuleType = vesyncpurifier fan_levels: list[int] = field(default_factory=list) modes: list[str] = field(default_factory=list) nightlight_modes: list[str] = field(default_factory=list) auto_preferences: list[str] = field(default_factory=list) @dataclass(kw_only=True) class AirFryerMap(DeviceMapTemplate): """Template for DeviceModules mapping. Attributes: dev_types (list[str]): List of device types to match from API. class_name (str): Class name of the device. product_type (str): Product type of the device - ProductTypes.AIR_FRYER module (ModuleType): Module for the device. setup_entry (str): Setup entry for the device, if unknown use the device_type base without region model_display (str): Display name of the model. model_name (str): Name of the model. device_alias (str | None): Alias for the device, if any. features (list[str]): List of features for the device. product_type (str): Product type of the device. module (ModuleType): Module for the device. """ temperature_range_f: tuple[int, int] = (200, 400) temperature_range_c: tuple[int, int] = (75, 200) product_line: str = ProductLines.WIFI_KITCHEN product_type: str = ProductTypes.AIR_FRYER module: ModuleType = vesynckitchen @dataclass(kw_only=True) class ThermostatMap(DeviceMapTemplate): """Template for Thermostat device mapping. Attributes: dev_types (list[str]): List of device types to match from API. class_name (str): Class name of the device. product_type (str): Product type of the device. module (ModuleType): Module for the device. setup_entry (str): Setup entry for the device, if unknown use the device_type base without region model_display (str): Display name of the model. model_name (str): Name of the model. device_alias (str | None): Alias for the device, if any. features (list[str]): List of features for the device. modes (list[int]): List of modes for the device. fan_modes (list[int]): List of fan modes for the device. eco_types (list[int]): List of eco types for the device. hold_options (list[int]): List of hold options for the device. routine_types (list[int]): List of routine types for the device. """ product_line: str = ProductLines.THERMOSTAT product_type: str = ProductTypes.THERMOSTAT module: ModuleType = vesyncthermostat modes: list[int] = field(default_factory=list) fan_modes: list[int] = field(default_factory=list) eco_types: list[int] = field(default_factory=list) hold_options: list[int] = field(default_factory=list) routine_types: list[int] = field(default_factory=list) thermostat_modules = [ ThermostatMap( dev_types=['LTM-A401S-WUS'], class_name='VeSyncAuraThermostat', fan_modes=[ ThermostatFanModes.AUTO, ThermostatFanModes.CIRCULATE, ThermostatFanModes.ON, ], modes=[ ThermostatWorkModes.HEAT, ThermostatWorkModes.COOL, ThermostatWorkModes.AUTO, ThermostatWorkModes.OFF, ThermostatWorkModes.SMART_AUTO, ThermostatWorkModes.EM_HEAT, ], eco_types=[ ThermostatEcoTypes.BALANCE, ThermostatEcoTypes.COMFORT_FIRST, ThermostatEcoTypes.COMFORT_SECOND, ThermostatEcoTypes.ECO_FIRST, ThermostatEcoTypes.ECO_SECOND, ], hold_options=[ ThermostatHoldOptions.PERMANENTLY, ThermostatHoldOptions.FOUR_HOURS, ThermostatHoldOptions.TWO_HOURS, ThermostatHoldOptions.UNTIL_NEXT_SCHEDULED_ITEM, ], routine_types=[ ThermostatRoutineTypes.AWAY, ThermostatRoutineTypes.CUSTOM, ThermostatRoutineTypes.HOME, ThermostatRoutineTypes.SLEEP, ], setup_entry='LTM-A401S-WUS', model_display='LTM-A401S Series', model_name='Aura Thermostat', ) ] outlet_modules = [ OutletMap( dev_types=['wifi-switch-1.3'], class_name='VeSyncOutlet7A', features=[OutletFeatures.ENERGY_MONITOR], model_name='WiFi Outlet US/CA', model_display='ESW01-USA Series', setup_entry='wifi-switch-1.3', ), OutletMap( # TODO: Add energy dev_types=['ESW10-USA'], class_name='VeSyncESW10USA', features=[], model_name='10A WiFi Outlet USA', model_display='ESW10-USA Series', setup_entry='ESW03-USA', ), OutletMap( dev_types=['ESW01-EU'], class_name='VeSyncOutlet10A', features=[OutletFeatures.ENERGY_MONITOR], model_name='10A WiFi Outlet Europe', model_display='ESW01-EU', setup_entry='ESW01-EU', ), OutletMap( dev_types=['ESW15-USA'], class_name='VeSyncOutlet15A', features=[OutletFeatures.ENERGY_MONITOR, OutletFeatures.NIGHTLIGHT], nightlight_modes=[NightlightModes.ON, NightlightModes.OFF, NightlightModes.AUTO], model_name='15A WiFi Outlet US/CA', model_display='ESW15-USA Series', setup_entry='ESW15-USA', ), OutletMap( dev_types=['ESO15-TB'], class_name='VeSyncOutdoorPlug', features=[OutletFeatures.ENERGY_MONITOR], model_name='Outdoor Plug', model_display='ESO15-TB Series', setup_entry='ESO15-TB', ), OutletMap( dev_types=[ 'BSDOG01', 'WYSMTOD16A', 'WHOGPLUG', 'WM-PLUG', 'JXUK13APLUG', 'WYZYOGMINIPLUG', 'BSDOG02', 'HWPLUG16A', 'FY-PLUG', 'HWPLUG16', ], class_name='VeSyncOutletBSDGO1', features=[OutletFeatures.ONOFF], model_name='Smart Plug', model_display='Smart Plug Series', setup_entry='BSDOG01', device_alias='Greensun Smart Plug', ), ] """List of ['OutletMap'][pyvesync.device_map.OutletMap] configuration objects for outlet devices.""" switch_modules = [ SwitchMap( dev_types=['ESWL01'], class_name='VeSyncWallSwitch', device_alias='Wall Switch', features=[SwitchFeatures.ONOFF], model_name='Light Switch', model_display='ESWL01 Series', setup_entry='ESWL01', ), SwitchMap( dev_types=['ESWD16'], class_name='VeSyncDimmerSwitch', features=[ SwitchFeatures.DIMMABLE, SwitchFeatures.INDICATOR_LIGHT, SwitchFeatures.BACKLIGHT_RGB, ], device_alias='Dimmer Switch', model_name='Dimmer Switch', model_display='ESWD16 Series', setup_entry='ESWD16', ), SwitchMap( dev_types=['ESWL03'], class_name='VeSyncWallSwitch', device_alias='Three-Way Wall Switch', features=[SwitchFeatures.ONOFF], model_name='Light Switch 3 way', model_display='ESWL03 Series', setup_entry='ESWL03', ), ] """List of ['SwitchMap'][pyvesync.device_map.SwitchMap] configuration objects for switch devices.""" bulb_modules = [ BulbMap( dev_types=['ESL100'], class_name='VeSyncBulbESL100', features=[SwitchFeatures.DIMMABLE], color_model=None, device_alias='Dimmable Bright White Bulb', color_modes=[ColorMode.WHITE], model_display='ESL100 Series', model_name='Soft white light bulb', setup_entry='ESL100', ), BulbMap( dev_types=['ESL100CW'], class_name='VeSyncBulbESL100CW', features=[BulbFeatures.DIMMABLE, BulbFeatures.COLOR_TEMP], color_model=None, device_alias='Dimmable Tunable White Bulb', color_modes=[ColorMode.WHITE], model_display='ESL100CW Series', model_name='Cool-to-Warm White Light Bulb', setup_entry='ESL100CW', ), BulbMap( dev_types=['XYD0001'], class_name='VeSyncBulbValcenoA19MC', features=[ BulbFeatures.DIMMABLE, BulbFeatures.MULTICOLOR, BulbFeatures.COLOR_TEMP, ], color_model=ColorMode.HSV, device_alias='Valceno Dimmable RGB Bulb', color_modes=[ColorMode.WHITE, ColorMode.COLOR], model_display='XYD0001', model_name='Valceno WiFi Bulb', setup_entry='XYD0001', ), BulbMap( dev_types=['ESL100MC'], class_name='VeSyncBulbESL100MC', features=[ BulbFeatures.MULTICOLOR, BulbFeatures.DIMMABLE, ], color_model=ColorMode.RGB, device_alias='Etekcity Dimmable RGB Bulb', color_modes=[ColorMode.WHITE, ColorMode.COLOR], model_name='Multicolor Bulb', model_display='ESL100MC', setup_entry='ESL100MC', ), ] """List of ['BulbMap'][pyvesync.device_map.BulbMap] configuration objects for bulb devices.""" humidifier_modules = [ HumidifierMap( class_name='VeSyncHumid200300S', dev_types=[ 'Classic300S', 'LUH-A601S-WUSB', 'LUH-A601S-AUSW', ], features=[ HumidifierFeatures.NIGHTLIGHT, HumidifierFeatures.NIGHTLIGHT_BRIGHTNESS, ], mist_modes={ HumidifierModes.AUTO: 'auto', HumidifierModes.SLEEP: 'sleep', HumidifierModes.MANUAL: 'manual', }, mist_levels=list(range(1, 10)), device_alias='Classic 300S', model_display='LUH-A601S Series', model_name='Classic 300S', setup_entry='Classic300S', ), HumidifierMap( class_name='VeSyncHumid200S', dev_types=['Classic200S'], features=[], mist_modes={ HumidifierModes.AUTO: 'auto', HumidifierModes.MANUAL: 'manual', }, mist_levels=list(range(1, 10)), device_alias='Classic 200S', model_display='Classic 200S Series', model_name='Classic 200S', setup_entry='Classic200S', ), HumidifierMap( class_name='VeSyncHumid200300S', dev_types=[ 'Dual200S', 'LUH-D301S-WUSR', 'LUH-D301S-WJP', 'LUH-D301S-WEU', 'LUH-D301S-KEUR', ], features=[], mist_modes={ HumidifierModes.AUTO: 'auto', HumidifierModes.MANUAL: 'manual', }, mist_levels=list(range(1, 3)), device_alias='Dual 200S', model_display='LUH-D301S Series', model_name='Dual 200S', setup_entry='Dual200S', ), HumidifierMap( class_name='VeSyncHumid200300S', dev_types=[ 'LUH-A602S-WUSR', 'LUH-A602S-WUS', 'LUH-A602S-WEUR', 'LUH-A602S-WEU', 'LUH-A602S-WJP', 'LUH-A602S-WUSC', ], features=[HumidifierFeatures.WARM_MIST], mist_modes={ HumidifierModes.AUTO: 'auto', HumidifierModes.SLEEP: 'sleep', HumidifierModes.MANUAL: 'manual', }, mist_levels=list(range(1, 10)), warm_mist_levels=[0, 1, 2, 3], device_alias='LV600S', model_display='LUH-A602S Series', model_name='LV600S', setup_entry='LUH-A602S-WUS', ), HumidifierMap( class_name='VeSyncHumid200300S', dev_types=['LUH-O451S-WEU'], features=[HumidifierFeatures.WARM_MIST], mist_modes={ HumidifierModes.AUTO: 'auto', HumidifierModes.SLEEP: 'sleep', HumidifierModes.MANUAL: 'manual', }, mist_levels=list(range(1, 10)), warm_mist_levels=list(range(4)), device_alias='OasisMist 450S EU', model_name='OasisMist 4.5L', model_display='LUH-O451S Series', setup_entry='LUH-O451S-WEU', ), HumidifierMap( class_name='VeSyncHumid200300S', dev_types=['LUH-O451S-WUS', 'LUH-O451S-WUSR', 'LUH-O601S-WUS', 'LUH-O601S-KUS'], features=[HumidifierFeatures.WARM_MIST], mist_modes={ HumidifierModes.AUTO: 'auto', HumidifierModes.SLEEP: 'sleep', HumidifierModes.MANUAL: 'manual', HumidifierModes.HUMIDITY: 'humidity', }, mist_levels=list(range(1, 10)), warm_mist_levels=list(range(4)), device_alias='OasisMist 450S', model_display='OasisMist 4.5L Series', model_name='OasisMist 4.5L', setup_entry='LUH-O451S-WUS', ), HumidifierMap( class_name='VeSyncHumid1000S', dev_types=['LUH-M101S-WUS', 'LUH-M101S-WEUR', 'LUH-M101S-WUSR'], features=[], mist_modes={ HumidifierModes.AUTO: 'auto', HumidifierModes.SLEEP: 'sleep', HumidifierModes.MANUAL: 'manual', }, mist_levels=list(range(1, 10)), device_alias='Oasismist 1000S', model_display='Oasismist Series', model_name='Oasismist 1000S', setup_entry='LUH-M101S', ), HumidifierMap( class_name='VeSyncSuperior6000S', dev_types=['LEH-S601S-WUS', 'LEH-S601S-WUSR', 'LEH-S601S-WEUR'], features=[HumidifierFeatures.DRYING_MODE], mist_modes={ HumidifierModes.AUTO: 'autoPro', HumidifierModes.SLEEP: 'sleep', HumidifierModes.HUMIDITY: 'humidity', HumidifierModes.MANUAL: 'manual', HumidifierModes.AUTOPRO: 'autoPro', }, mist_levels=list(range(1, 10)), device_alias='Superior 6000S', model_display='LEH-S601S Series', model_name='Superior 6000S', setup_entry='LEH-S601S', ), ] """List of ['HumidifierMap'][pyvesync.device_map.HumidifierMap] configuration objects for humidifier devices.""" purifier_modules: list[PurifierMap] = [ PurifierMap( class_name='VeSyncAirBypass', dev_types=['Core200S', 'LAP-C201S-AUSR', 'LAP-C202S-WUSR'], modes=[PurifierModes.SLEEP, PurifierModes.MANUAL], features=[PurifierFeatures.RESET_FILTER, PurifierFeatures.NIGHTLIGHT], auto_preferences=[ PurifierAutoPreference.DEFAULT, PurifierAutoPreference.EFFICIENT, PurifierAutoPreference.QUIET, ], fan_levels=list(range(1, 4)), nightlight_modes=[NightlightModes.ON, NightlightModes.OFF, NightlightModes.DIM], device_alias='Core 200S', model_display='Core 200S', model_name='Core 200S', setup_entry='Core200S', ), PurifierMap( class_name='VeSyncAirBypass', dev_types=[ 'Core300S', 'LAP-C301S-WJP', 'LAP-C302S-WUSB', 'LAP-C301S-WAAA', 'LAP-C302S-WGC', ], modes=[PurifierModes.SLEEP, PurifierModes.MANUAL, PurifierModes.AUTO], auto_preferences=[ PurifierAutoPreference.DEFAULT, PurifierAutoPreference.EFFICIENT, PurifierAutoPreference.QUIET, ], features=[PurifierFeatures.AIR_QUALITY], fan_levels=list(range(1, 4)), device_alias='Core 300S', model_display='Core 300S', model_name='Core 300S', setup_entry='Core300S', ), PurifierMap( class_name='VeSyncAirBypass', dev_types=['Core400S', 'LAP-C401S-WJP', 'LAP-C401S-WUSR', 'LAP-C401S-WAAA'], modes=[PurifierModes.SLEEP, PurifierModes.MANUAL, PurifierModes.AUTO], features=[PurifierFeatures.AIR_QUALITY], fan_levels=list(range(1, 5)), device_alias='Core 400S', auto_preferences=[ PurifierAutoPreference.DEFAULT, PurifierAutoPreference.EFFICIENT, PurifierAutoPreference.QUIET, ], model_display='Core 400S', model_name='Core 400S', setup_entry='Core400S', ), PurifierMap( class_name='VeSyncAirBypass', dev_types=['Core600S', 'LAP-C601S-WUS', 'LAP-C601S-WUSR', 'LAP-C601S-WEU'], modes=[PurifierModes.SLEEP, PurifierModes.MANUAL, PurifierModes.AUTO], features=[PurifierFeatures.AIR_QUALITY], auto_preferences=[ PurifierAutoPreference.DEFAULT, PurifierAutoPreference.EFFICIENT, PurifierAutoPreference.QUIET, ], fan_levels=list(range(1, 5)), device_alias='Core 600S', model_display='Core 600S', model_name='Core 600S', setup_entry='Core600S', ), PurifierMap( class_name='VeSyncAir131', dev_types=['LV-PUR131S', 'LV-RH131S', 'LV-RH131S-WM'], modes=[PurifierModes.SLEEP, PurifierModes.MANUAL, PurifierModes.AUTO], features=[PurifierFeatures.AIR_QUALITY], fan_levels=list(range(1, 4)), device_alias='LV-PUR131S', model_display='LV-PUR131S/RH131S Series', model_name='LV131S', setup_entry='LV-PUR131S', ), PurifierMap( class_name='VeSyncAirBaseV2', dev_types=[ 'LAP-V102S-AASR', 'LAP-V102S-WUS', 'LAP-V102S-WEU', 'LAP-V102S-AUSR', 'LAP-V102S-WJP', 'LAP-V102S-AJPR', 'LAP-V102S-AEUR', ], modes=[ PurifierModes.SLEEP, PurifierModes.MANUAL, PurifierModes.AUTO, PurifierModes.PET, ], features=[PurifierFeatures.AIR_QUALITY], fan_levels=list(range(1, 5)), device_alias='Vital 100S', auto_preferences=[ PurifierAutoPreference.DEFAULT, PurifierAutoPreference.EFFICIENT, PurifierAutoPreference.QUIET, ], model_display='LAP-V102S Series', model_name='Vital 100S', setup_entry='LAP-V102S', ), PurifierMap( class_name='VeSyncAirBaseV2', dev_types=[ 'LAP-V201S-AASR', 'LAP-V201S-WJP', 'LAP-V201S-WEU', 'LAP-V201S-WUS', 'LAP-V201-AUSR', 'LAP-V201S-AUSR', 'LAP-V201S-AEUR', ], modes=[ PurifierModes.SLEEP, PurifierModes.MANUAL, PurifierModes.AUTO, PurifierModes.PET, ], features=[PurifierFeatures.AIR_QUALITY, PurifierFeatures.LIGHT_DETECT], fan_levels=list(range(1, 5)), device_alias='Vital 200S', auto_preferences=[ PurifierAutoPreference.DEFAULT, PurifierAutoPreference.EFFICIENT, PurifierAutoPreference.QUIET, ], model_display='LAP-V201S Series', model_name='Vital 200S', setup_entry='LAP-V201S', ), PurifierMap( class_name='VeSyncAirBaseV2', dev_types=[ 'LAP-EL551S-AUS', 'LAP-EL551S-AEUR', 'LAP-EL551S-WEU', 'LAP-EL551S-WUS', ], modes=[ PurifierModes.SLEEP, PurifierModes.MANUAL, PurifierModes.AUTO, PurifierModes.TURBO, ], features=[ PurifierFeatures.AIR_QUALITY, PurifierFeatures.VENT_ANGLE, PurifierFeatures.LIGHT_DETECT, ], fan_levels=list(range(1, 4)), device_alias='Everest Air', auto_preferences=[ PurifierAutoPreference.DEFAULT, PurifierAutoPreference.EFFICIENT, PurifierAutoPreference.QUIET, ], model_display='LAP-EL551S Series', model_name='Everest Air', setup_entry='EL551S', ), PurifierMap( class_name='VeSyncAirSprout', dev_types=[ 'LAP-B851S-WEU', 'LAP-B851S-WNA', 'LAP-B851S-AEUR', 'LAP-B851S-AUS', 'LAP-B851S-WUS', 'LAP-BAY-MAX01S', ], modes=[ PurifierModes.SLEEP, PurifierModes.MANUAL, PurifierModes.AUTO, ], features=[ PurifierFeatures.AIR_QUALITY, PurifierFeatures.NIGHTLIGHT, ], fan_levels=list(range(1, 4)), device_alias='Sprout Air Purifier', auto_preferences=[ PurifierAutoPreference.DEFAULT, PurifierAutoPreference.EFFICIENT, PurifierAutoPreference.QUIET, ], model_display='Sprout Air Series', model_name='Sprout Air', setup_entry='LAP-B851S-WUS', ), ] """List of ['PurifierMap'][pyvesync.device_map.PurifierMap] configuration objects for purifier devices.""" fan_modules: list[FanMap] = [ FanMap( class_name='VeSyncTowerFan', dev_types=['LTF-F422S-KEU', 'LTF-F422S-WUSR', 'LTF-F422S-WJP', 'LTF-F422S-WUS'], modes=[ FanModes.NORMAL, FanModes.TURBO, FanModes.AUTO, FanModes.ADVANCED_SLEEP, ], set_mode_method='setTowerFanMode', features=[ FanFeatures.OSCILLATION, FanFeatures.DISPLAYING_TYPE, FanFeatures.SOUND, ], fan_levels=list(range(1, 13)), device_alias='Tower Fan', sleep_preferences=[ FanSleepPreference.DEFAULT, FanSleepPreference.ADVANCED, FanSleepPreference.TURBO, FanSleepPreference.EFFICIENT, FanSleepPreference.QUIET, ], # Unknown sleep preferences, need to be verified model_display='LTF-F422S Series', model_name='Classic 42-Inch Tower Fan', setup_entry='LTF-F422S', ), ] """List of ['FanMap'][pyvesync.device_map.FanMap] configuration objects for fan devices.""" air_fryer_modules: list[AirFryerMap] = [ AirFryerMap( class_name='VeSyncAirFryer158', module=vesynckitchen, dev_types=['CS137-AF/CS158-AF', 'CS158-AF', 'CS137-AF'], device_alias='Air Fryer', model_display='CS158/159/168/169-AF Series', model_name='Smart/Pro/Pro Gen 2 5.8 Qt. Air Fryer', setup_entry='CS137-AF/CS158-AF', ) ] """List of ['AirFryerMap'][pyvesync.device_map.AirFryerMap] configuration for air fryer devices.""" full_device_list = [ *fan_modules, *purifier_modules, *humidifier_modules, *air_fryer_modules, *thermostat_modules, ] """List of all device configuration objects.""" def get_device_config(device_type: str) -> DeviceMapTemplate | None: """Get general device details from device type to create instance. Args: device_type (str): Device type to match from device list API call. Returns: DeviceMapTemplate | None: DeviceMapTemplate object or None if not found. """ all_modules: list[T_MAPS] = [ outlet_modules, switch_modules, bulb_modules, fan_modules, purifier_modules, humidifier_modules, air_fryer_modules, thermostat_modules, ] for module in chain(*all_modules): if device_type in module.dev_types: return module if device_type.count('-') > 1: device_type = '-'.join(device_type.split('-')[:-1]) for module in chain(*all_modules): if any(device_type.lower() in dev.lower() for dev in module.dev_types): return module return None def get_fan(device_type: str) -> FanMap | None: """Get fan device details from device type. Args: device_type (str): Device type to match from device list API call. Returns: FanMap | None: FanMap object or None if not found. """ for module in fan_modules: if device_type in module.dev_types: return module # Try to match with a more generic device type if device_type.count('-') > 1: device_type = '-'.join(device_type.split('-')[:-1]) for module in fan_modules: if any(device_type.lower() in dev.lower() for dev in module.dev_types): return module return None def get_purifier(device_type: str) -> PurifierMap | None: """Get purifier device details from device type. Args: device_type (str): Device type to match from device list API call. Returns: PurifierMap | None: PurifierMap object or None if not found. """ for module in purifier_modules: if device_type in module.dev_types: return module if device_type.count('-') > 1: device_type = '-'.join(device_type.split('-')[:-1]) for module in purifier_modules: if any(device_type.lower() in dev.lower() for dev in module.dev_types): return module return None def get_humidifier(device_type: str) -> HumidifierMap | None: """Get humidifier device details from device type. Args: device_type (str): Device type to match from device list API call. Returns: HumidifierMap | None: HumidifierMap object or None if not found. """ for module in humidifier_modules: if device_type in module.dev_types: return module if device_type.count('-') > 1: device_type = '-'.join(device_type.split('-')[:-1]) for module in humidifier_modules: if any(device_type.lower() in dev.lower() for dev in module.dev_types): return module return None def get_outlet(device_type: str) -> OutletMap | None: """Get outlet device details from device type. Args: device_type (str): Device type to match from device list API call. Returns: OutletMap | None: OutletMap object or None if not found. """ for module in outlet_modules: if device_type in module.dev_types: return module if device_type.count('-') > 1: device_type = '-'.join(device_type.split('-')[:-1]) for module in outlet_modules: if any(device_type.lower() in dev.lower() for dev in module.dev_types): return module return None def get_switch(device_type: str) -> SwitchMap | None: """Get switch device details from device type. Args: device_type (str): Device type to match from device list API call. Returns: SwitchMap | None: SwitchMap object or None if not found. """ for module in switch_modules: if device_type in module.dev_types: return module if device_type.count('-') > 1: device_type = '-'.join(device_type.split('-')[:-1]) for module in switch_modules: if any(device_type.lower() in dev.lower() for dev in module.dev_types): return module return None def get_bulb(device_type: str) -> BulbMap | None: """Get bulb device details from device type. Args: device_type (str): Device type to match from device list API call. Returns: BulbMap | None: BulbMap object or None if not found. """ for module in bulb_modules: if device_type in module.dev_types: return module if device_type.count('-') > 1: device_type = '-'.join(device_type.split('-')[:-1]) for module in bulb_modules: if any(device_type.lower() in dev.lower() for dev in module.dev_types): return module return None def get_air_fryer(device_type: str) -> AirFryerMap | None: """Get air fryer device details from device type. Args: device_type (str): Device type to match from device list API call. Returns: AirFryerMap | None: AirFryerMap object or None if not found. """ for module in air_fryer_modules: if device_type in module.dev_types: return module if device_type.count('-') > 1: device_type = '-'.join(device_type.split('-')[:-1]) for module in air_fryer_modules: if any(device_type.lower() in dev.lower() for dev in module.dev_types): return module return None def get_thermostat(device_type: str) -> ThermostatMap | None: """Get the device map for a thermostat. Args: device_type (str): The device type to match. Returns: ThermostatMap | None: The matching thermostat map or None if not found. """ for module in thermostat_modules: if device_type in module.dev_types: return module if device_type.count('-') > 1: device_type = '-'.join(device_type.split('-')[:-1]) for module in thermostat_modules: if any(device_type.lower() in dev.lower() for dev in module.dev_types): return module return None webdjoe-pyvesync-eb8cecb/src/pyvesync/devices/000077500000000000000000000000001507433633000217415ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/src/pyvesync/devices/__init__.py000066400000000000000000000000671507433633000240550ustar00rootroot00000000000000"""Device class implementations for VeSync devices.""" webdjoe-pyvesync-eb8cecb/src/pyvesync/devices/vesyncbulb.py000066400000000000000000001234341507433633000244760ustar00rootroot00000000000000"""Etekcity/Valceno Smart Light Bulbs. This module provides classes for the following Etekcity/Valceno smart lights: 1. ESL100: Dimmable Bulb 2. ESL100CW: Tunable White Bulb 3. XYD0001: RGB Bulb 4. ESL100MC: Multi-Color Bulb The classes all inherit from VeSyncBulb, which is a subclass of VeSyncBaseDevice and [VeSyncToggleDevice][pyvesync.base_devices.vesyncbasedevice.VeSyncBaseToggleDevice]. """ from __future__ import annotations import logging from collections.abc import Mapping from typing import TYPE_CHECKING from typing_extensions import deprecated from pyvesync.base_devices import VeSyncBulb from pyvesync.const import ConnectionStatus, DeviceStatus from pyvesync.models import bulb_models from pyvesync.models.base_models import DefaultValues from pyvesync.models.bypass_models import TimerModels from pyvesync.utils.colors import Color from pyvesync.utils.device_mixins import ( BypassV1Mixin, BypassV2Mixin, process_bypassv1_result, process_bypassv2_result, ) from pyvesync.utils.helpers import Helpers, Timer, Validators if TYPE_CHECKING: from pyvesync import VeSync from pyvesync.device_map import BulbMap from pyvesync.models.vesync_models import ResponseDeviceDetailsModel logger = logging.getLogger(__name__) NUMERIC_OPT = int | float | str | None NUMERIC_STRICT = float | int | str class VeSyncBulbESL100MC(BypassV2Mixin, VeSyncBulb): """Etekcity ESL100 Multi Color Bulb device. Inherits from [VeSyncBulb][pyvesync.base_devices.bulb_base.VeSyncBulb] and [VeSyncBaseDevice][pyvesync.base_devices.vesyncbasedevice.VeSyncBaseDevice]. The state of the bulb is stored in the `state` attribute, which is an of [BulbState][pyvesync.base_devices.bulb_base.BulbState]. The `state` attribute contains all settable states for the bulb. Args: details (dict): Dictionary of bulb state details. manager (VeSync): Manager class used to make API calls. feature_map (BulbMap): Device configuration map. Attributes: state (BulbState): Device state object Each device has a separate state base class in the base_devices module. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. Notes: The details dictionary contains the device information retreived by the `update()` method: ```python details = { 'brightness': 50, 'colorMode': 'rgb', 'color' : Color(red=0, green=0, blue=0) } ``` See pyvesync.helpers.color.Color for more information on the Color dataclass. """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: BulbMap ) -> None: """Instantiate ESL100MC Multicolor Bulb.""" super().__init__(details, manager, feature_map) async def get_details(self) -> None: r_dict = await self.call_bypassv2_api('getLightStatus') result_model = process_bypassv2_result( self, logger, 'get_details', r_dict, bulb_models.ResponseESL100MCResult ) if result_model is None: return self._set_state(result_model) return def _set_state(self, response: bulb_models.ResponseESL100MCResult) -> None: """Build detail dictionary from response.""" self.state.brightness = response.brightness self.state.color_mode = response.colorMode self.state.color = Color.from_rgb( red=response.red, green=response.green, blue=response.blue ) async def set_brightness(self, brightness: int) -> bool: return await self.set_status(brightness=brightness) async def set_rgb(self, red: float, green: float, blue: float) -> bool: return await self.set_status(red=red, green=green, blue=blue) async def set_hsv(self, hue: float, saturation: float, value: float) -> bool: hsv = Color.from_hsv(hue=hue, saturation=saturation, value=value) if hsv is not None: return await self.set_status( red=hsv.rgb.red, green=hsv.rgb.green, blue=hsv.rgb.blue ) logger.debug('Invalid HSV values') return False async def set_white_mode(self) -> bool: return await self.set_status(brightness=100) async def set_status( self, brightness: float | None = None, red: float | None = None, green: float | None = None, blue: float | None = None, ) -> bool: """Set color of VeSync ESL100MC. Brightness or RGB values must be provided. If RGB values are provided, brightness is ignored. Args: brightness (float | None): Brightness of bulb (0-100). red (float | None): Red value of RGB color, 0-255. green (float | None): Green value of RGB color, 0-255. blue (float | None): Blue value of RGB color, 0-255. Returns: bool: True if successful, False otherwise. """ brightness_update = 100 if red is not None and green is not None and blue is not None: new_color = Color.from_rgb(red, green, blue) color_mode = 'color' if ( self.state.device_status == DeviceStatus.ON and new_color == self.state.color ): logger.debug('New color is same as current color') return True else: logger.debug('RGB Values not provided') new_color = None if brightness is not None: if Validators.validate_zero_to_hundred(brightness): brightness_update = int(brightness) else: logger.debug('Invalid brightness value') return False if ( self.state.device_status == DeviceStatus.ON and brightness_update == self.state.brightness ): logger.debug('Brightness already set to %s', brightness) return True color_mode = 'white' else: logger.debug('Brightness and RGB values are not set') return False data = { 'action': DeviceStatus.ON, 'speed': 0, 'brightness': brightness_update, 'red': 0 if new_color is None else int(new_color.rgb.red), 'green': 0 if new_color is None else int(new_color.rgb.green), 'blue': 0 if new_color is None else int(new_color.rgb.blue), 'colorMode': 'color' if new_color is not None else 'white', } r_dict = await self.call_bypassv2_api('setLightStatus', data) r = Helpers.process_dev_response(logger, 'set_status', self, r_dict) if r is None: return False if color_mode == 'color' and new_color is not None: self.state.color_mode = 'color' self.state.color = new_color elif brightness is not None: self.state.brightness = int(brightness_update) self.state.color_mode = 'white' self.state.device_status = DeviceStatus.ON self.state.connection_status = ConnectionStatus.ONLINE return True @deprecated( 'toggle() is deprecated, use toggle_switch(toggle: bool | None = None) instead' ) async def toggle(self, status: str) -> bool: """Toggle switch of VeSync ESL100MC.""" status_bool = status == DeviceStatus.ON return await self.toggle_switch(status_bool) async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.device_status == DeviceStatus.OFF data = {'id': 0, 'enabled': toggle} r_dict = await self.call_bypassv2_api('setSwitch', data) r = Helpers.process_dev_response(logger, 'toggle', self, r_dict) if r is None: return False self.state.device_status = DeviceStatus.ON if toggle else DeviceStatus.OFF return True class VeSyncBulbESL100(BypassV1Mixin, VeSyncBulb): """Object to hold VeSync ESL100 light bulb. Device state is held in the `state` attribute, which is an instance of [BulbState][pyvesync.base_devices.bulb_base.BulbState]. The `state` attribute contains all settable states for the bulb. This bulb only has the dimmable feature. Inherits from [VeSyncBulb][pyvesync.devices.vesyncbulb.VeSyncBulb] and [VeSyncBaseToggleDevice][pyvesync.base_devices.vesyncbasedevice.VeSyncBaseToggleDevice]. Args: details (dict): Dictionary of bulb state details. manager (VeSync): Manager class used to make API calls feature_map (BulbMap): Device configuration map. Attributes: state (BulbState): Device state object Each device has a separate state base class in the base_devices module. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: BulbMap ) -> None: """Initialize Etekcity ESL100 Dimmable Bulb.""" super().__init__(details, manager, feature_map) async def get_details(self) -> None: method_dict = { 'method': 'deviceDetail', } r_dict = await self.call_bypassv1_api( bulb_models.RequestESL100Detail, method_dict, 'deviceDetail', 'deviceDetail' ) model = process_bypassv1_result( self, logger, 'get_details', r_dict, bulb_models.ResponseESL100DetailResult ) if model is None: self.state.connection_status = ConnectionStatus.OFFLINE return self.state.brightness = model.brightness self.state.device_status = model.deviceStatus self.state.connection_status = model.connectionStatus @deprecated( 'toggle() is deprecated, use toggle_switch(toggle: bool | None = None) instead' ) async def toggle(self, status: str) -> bool: """Toggle switch of ESL100 bulb.""" status_bool = status != DeviceStatus.ON return await self.toggle_switch(status_bool) async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.device_status != DeviceStatus.ON status = DeviceStatus.ON if toggle else DeviceStatus.OFF method_dict = { 'status': status, } r_dict = await self.call_bypassv1_api( bulb_models.RequestESL100Status, method_dict, 'smartBulbPowerSwitchCtl', 'smartBulbPowerSwitchCtl', ) r = Helpers.process_dev_response(logger, 'toggle', self, r_dict) if r is None: return False self.state.device_status = status return True @deprecated('Use set_brightness() instead') async def set_status(self, brightness: int) -> bool: """Set brightness of dimmable bulb. Args: brightness (int): Brightness of bulb (0-100). Returns: bool: True if successful, False otherwise. """ return await self.set_brightness(brightness=brightness) async def set_brightness(self, brightness: int) -> bool: if not self.supports_brightness: logger.warning('%s is not dimmable', self.device_name) return False if not Validators.validate_zero_to_hundred(brightness): logger.debug('Invalid brightness value') return False brightness_update = brightness if ( self.state.device_status == DeviceStatus.ON and brightness_update == self.supports_brightness ): logger.debug('Device already in requested state') return True method_dict = { 'brightNess': str(brightness_update), 'status': 'on', } r_dict = await self.call_bypassv1_api( bulb_models.RequestESL100Brightness, method_dict, 'smartBulbBrightnessCtl', 'smartBulbBrightnessCtl', ) r = Helpers.process_dev_response(logger, 'set_brightness', self, r_dict) if r is None: return False self.state.brightness = brightness_update self.state.device_status = DeviceStatus.ON self.state.connection_status = ConnectionStatus.ONLINE return True async def get_timer(self) -> None: r_dict = await self.call_bypassv1_api( TimerModels.RequestV1GetTimer, {}, 'getTimers', 'timer/getTimers' ) result_model = process_bypassv1_result( self, logger, 'get_timer', r_dict, TimerModels.ResultV1GetTimer ) if result_model is None: return if not isinstance(result_model.timers, list) or not result_model.timers: self.state.timer = None logger.debug('No timers found') return timer = result_model.timers if not isinstance(timer, TimerModels.TimerItemV1): logger.debug('Invalid timer item type') return self.state.timer = Timer( int(timer.counterTimer), timer.action, int(timer.timerID), ) async def set_timer(self, duration: int, action: str | None = None) -> bool: if action is None: action = ( DeviceStatus.ON if self.state.device_status == DeviceStatus.OFF else DeviceStatus.OFF ) if action not in [DeviceStatus.ON, DeviceStatus.OFF]: logger.debug("Invalid action value - must be 'on' or 'off'") return False update_dict = { 'action': action, 'counterTime': str(duration), 'status': '1', } r_dict = await self.call_bypassv1_api( TimerModels.RequestV1SetTime, update_dict, 'addTimer', 'timer/addTimer' ) result_model = process_bypassv1_result( self, logger, 'set_timer', r_dict, TimerModels.ResultV1SetTimer ) if result_model is None: return False self.state.timer = Timer(duration, action, int(result_model.timerID)) return True async def clear_timer(self) -> bool: if self.state.timer is None: logger.debug('No timer set - run get_timer() first') return False timer = self.state.timer r_dict = await self.call_bypassv1_api( TimerModels.RequestV1ClearTimer, {'timerId': str(timer.id), 'status': '1'}, 'deleteTimer', 'timer/deleteTimer', ) result = Helpers.process_dev_response(logger, 'clear_timer', self, r_dict) if result is None: return False self.state.timer = None return True class VeSyncBulbESL100CW(BypassV1Mixin, VeSyncBulb): """VeSync Tunable and Dimmable White Bulb. This bulb only has the dimmable feature. Inherits from [VeSyncBulb][pyvesync.devices.vesyncbulb.VeSyncBulb] and [VeSyncBaseToggleDevice][pyvesync.base_devices.vesyncbasedevice.VeSyncBaseToggleDevice]. Device state is held in the `state` attribute, which is an instance of [BulbState][pyvesync.base_devices.bulb_base.BulbState]. The `state` attribute contains all settable states for the bulb. Args: details (dict): Dictionary of bulb state details. manager (VeSync): Manager class used to make API calls feature_map (BulbMap): Device configuration map. Attributes: state (BulbState): Device state object Each device has a separate state base class in the base_devices module. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: BulbMap ) -> None: """Initialize Etekcity Tunable white bulb.""" super().__init__(details, manager, feature_map) async def get_details(self) -> None: r_dict = await self.call_bypassv1_api( bulb_models.RequestESL100CWBase, {'jsonCmd': {'getLightStatus': 'get'}}, ) light_resp = process_bypassv1_result( self, logger, 'get_details', r_dict, bulb_models.ResponseESL100CWDetailResult ) if light_resp is None: self.state.connection_status = ConnectionStatus.OFFLINE return self._interpret_apicall_result(light_resp) def _interpret_apicall_result( self, response: bulb_models.ResponseESL100CWDetailResult ) -> None: self.state.connection_status = ConnectionStatus.ONLINE result = response.light self.state.device_status = result.action self.state.brightness = result.brightness self.state.color_temp = result.colorTempe @deprecated( 'toggle() is deprecated, use toggle_switch(toggle: bool | None = None) instead' ) async def toggle(self, status: str) -> bool: """Deprecated - use toggle_switch().""" status_bool = status == DeviceStatus.ON return await self.toggle_switch(status_bool) async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.device_status == DeviceStatus.OFF status = DeviceStatus.ON if toggle else DeviceStatus.OFF r_dict = await self.call_bypassv1_api( bulb_models.RequestESL100CWBase, {'jsonCmd': {'light': {'action': status}}}, 'bypass', 'bypass', ) r = Helpers.process_dev_response(logger, 'toggle', self, r_dict) if r is None: logger.debug('%s offline', self.device_name) return False self.state.device_status = status return True async def set_brightness(self, brightness: int) -> bool: """Set brightness of tunable bulb.""" return await self.set_status(brightness=brightness) async def set_status( self, /, brightness: int | None = None, color_temp: int | None = None ) -> bool: """Set status of tunable bulb.""" if brightness is not None: if Validators.validate_zero_to_hundred(brightness): brightness_update = int(brightness) else: logger.debug('Invalid brightness value') return False elif self.state.brightness is not None: brightness_update = self.state.brightness else: brightness_update = 100 if color_temp is not None: if Validators.validate_zero_to_hundred(color_temp): color_temp_update = color_temp else: logger.debug('Invalid color temperature value') return False elif self.state.color_temp is not None: color_temp_update = self.state.color_temp else: color_temp_update = 100 if ( self.state.device_status == DeviceStatus.ON and brightness_update == self.state.brightness and color_temp_update == self.state.color_temp ): logger.debug('Device already in requested state') return True light_dict: dict[str, NUMERIC_OPT | str] = { 'colorTempe': color_temp_update, 'brightness': brightness_update, 'action': DeviceStatus.ON, } r_dict = await self.call_bypassv1_api( bulb_models.RequestESL100CWBase, {'jsonCmd': {'light': light_dict}}, 'bypass', 'bypass', ) r = Helpers.process_dev_response(logger, 'set_brightness', self, r_dict) if r is None: return False self.state.brightness = brightness_update self.state.color_temp = color_temp_update self.state.device_status = DeviceStatus.ON self.state.connection_status = ConnectionStatus.ONLINE return True async def set_color_temp(self, color_temp: int) -> bool: return await self.set_status(color_temp=color_temp) async def get_timer(self) -> None: r_dict = await self.call_bypassv1_api( TimerModels.RequestV1GetTimer, {}, 'getTimers', 'timer/getTimers' ) result_model = process_bypassv1_result( self, logger, 'get_timer', r_dict, TimerModels.ResultV1GetTimer ) if result_model is None: return if not isinstance(result_model.timers, list) or not result_model.timers: logger.debug('No timers found') return timers = result_model.timers if len(timers) > 1: logger.debug('Multiple timers found, returning first timer') timer = timers[0] if not isinstance(timer, TimerModels.TimeItemV1): logger.debug('Invalid timer item type') return self.state.timer = Timer( int(timer.counterTime), timer.action, int(timer.timerID), ) async def set_timer(self, duration: int, action: str | None = None) -> bool: if action is None: action = ( DeviceStatus.ON if self.state.device_status == DeviceStatus.OFF else DeviceStatus.OFF ) if action not in [DeviceStatus.ON, DeviceStatus.OFF]: logger.debug("Invalid action value - must be 'on' or 'off'") return False update_dict = { 'action': action, 'counterTime': str(duration), 'status': '1', } r_dict = await self.call_bypassv1_api( TimerModels.RequestV1SetTime, update_dict, 'addTimer', 'timer/addTimer' ) result_model = process_bypassv1_result( self, logger, 'set_timer', r_dict, TimerModels.ResultV1SetTimer ) if result_model is None: return False self.state.timer = Timer(duration, action, int(result_model.timerID)) return True async def clear_timer(self) -> bool: if self.state.timer is None: logger.debug('No timer set - run get_timer() first') return False timer = self.state.timer r_dict = await self.call_bypassv1_api( TimerModels.RequestV1ClearTimer, {'timerId': str(timer.id), 'status': '1'}, 'deleteTimer', 'timer/deleteTimer', ) r = Helpers.process_dev_response(logger, 'clear_timer', self, r_dict) if r is None: return False self.state.timer = None return True class VeSyncBulbValcenoA19MC(VeSyncBulb): """VeSync Multicolor Bulb. This bulb only has the dimmable feature. Inherits from [VeSyncBulb][pyvesync.devices.vesyncbulb.VeSyncBulb] and [VeSyncBaseToggleDevice][pyvesync.base_devices.vesyncbasedevice.VeSyncBaseToggleDevice]. Device state is held in the `state` attribute, which is an instance of [BulbState][pyvesync.base_devices.bulb_base.BulbState]. The `state` attribute contains all settable states for the bulb. Args: details (dict): Dictionary of bulb state details. manager (VeSync): Manager class used to make API calls feature_map (BulbMap): Device configuration map. Attributes: state (BulbState): Device state object Each device has a separate state base class in the base_devices module. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: BulbMap ) -> None: """Initialize Multicolor bulb.""" super().__init__(details, manager, feature_map) self.request_keys = [ 'acceptLanguage', 'accountID', 'appVersion', 'cid', 'configModule', 'deviceRegion', 'debugMode', 'phoneBrand', 'phoneOS', 'timeZone', 'token', 'traceId', ] def _payload_base(self) -> bulb_models.ValcenoStatusPayload: """Return the payload base for the set status request. Avoid duplicating code and ensure that the payload dict is reset to its default state before each request. """ payload_dict: bulb_models.ValcenoStatusPayload = { 'force': 0, 'brightness': '', 'colorTemp': '', 'colorMode': '', 'hue': '', 'saturation': '', 'value': '', } return payload_dict def _build_request(self, payload: dict) -> dict: """Build request for Valceno Smart Bulb. The request is built from the keys in `self.request_keys` and the `payload` dict. The payload is then added to the request body. Args: method (str): The method to use for the request. payload (dict): The data to use for the payload. Returns: dict: The request body. Note: The `payload` argument is the value of request_body['payload']. The request structure is: ```json { "acceptLanguage": "en", "accountID": "1234567890", "appVersion": "1.0.0", "cid": "1234567890", "configModule": "default", "deviceRegion": "US", "debugMode": False, "phoneBrand": "Apple", "phoneOS": "iOS", "timeZone": "New_York", "token": "", "traceId": "", "payload": { "method": "getLightStatusV2", "source": "APP", "data": {} } } ``` """ default_dict = Helpers.get_class_attributes(DefaultValues, self.request_keys) default_dict.update(Helpers.get_class_attributes(self, self.request_keys)) default_dict.update(Helpers.get_class_attributes(self.manager, self.request_keys)) default_dict['method'] = 'bypassV2' default_dict['payload'] = payload return default_dict async def _call_valceno_api( self, payload_method: str, payload_data: Mapping ) -> dict | None: """Make API call to Valceno Smart Bulb. Args: payload_method (str): The method to use for the request. payload_data (dict): The payload to use for the request. Returns: tuple[bytes, Any]: The response from the API call. Note: The request is built by the `_build_request` method and the payload is added to the request body. The payload structure is: ```json { "method": "getLightStatusV2", "source": "APP", "data": {} } """ payload = {'method': payload_method, 'source': 'APP', 'data': payload_data} request_body = self._build_request(payload) r_dict, _ = await self.manager.async_call_api( '/cloud/v2/deviceManaged/bypassV2', 'post', headers=Helpers.req_header_bypass(), json_object=request_body, ) resp = Helpers.process_dev_response(logger, payload_method, self, r_dict) if resp is None: return None return r_dict async def get_details(self) -> None: r_dict = await self._call_valceno_api('getLightStatusV2', {}) if r_dict is None: return status = bulb_models.ResponseValcenoStatus.from_dict(r_dict) self._interpret_apicall_result(status) def _interpret_apicall_result( self, response: bulb_models.ResponseValcenoStatus ) -> None: """Process API response with device status. Assigns the values to the state attributes of the device. Args: response (bulb_models.ResponseValcenoStatus): The response from the API call. """ result = response.result.result self.state.connection_status = ConnectionStatus.ONLINE self.state.device_status = result.enabled self.state.brightness = result.brightness self.state.color_temp = result.colorTemp self.state.color_mode = result.colorMode hue = float(round(result.hue / 250 * 9, 2)) sat = float(result.saturation / 100) val = float(result.value) self.state.color = Color.from_hsv(hue=hue, saturation=sat, value=val) @deprecated( 'toggle() is deprecated, use toggle_switch(toggle: bool | None = None) instead' ) async def toggle(self, status: str) -> bool: """Deprecated - use toggle_switch().""" status_bool = status == DeviceStatus.ON return await self.toggle_switch(status_bool) async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.device_status == DeviceStatus.OFF if toggle == self.state.device_status: logger.debug('Device already in requested state') return True payload_data = { 'id': 0, 'enabled': toggle, } method = 'setSwitch' r_dict = await self._call_valceno_api(method, payload_data) if r_dict is None: self.state.device_status = DeviceStatus.OFF return False self.state.device_status = DeviceStatus.ON if toggle else DeviceStatus.OFF self.state.connection_status = ConnectionStatus.ONLINE return True async def set_rgb(self, red: float, green: float, blue: float) -> bool: new_color = Color.from_rgb(red=red, green=green, blue=blue) if new_color is None: logger.debug('Invalid RGB values') return False return await self.set_hsv( hue=new_color.hsv.hue, saturation=new_color.hsv.saturation, value=new_color.hsv.value, ) async def set_brightness(self, brightness: int) -> bool: """Set brightness of multicolor bulb.""" return await self.set_status(brightness=brightness) async def set_color_temp(self, color_temp: int) -> bool: """Set White Temperature of Bulb in pct (0 - 100).""" return await self.set_status(color_temp=color_temp) async def set_color_hue(self, color_hue: float) -> bool: """Set Color Hue of Bulb (0 - 360).""" return await self.set_status(color_hue=color_hue) async def set_color_saturation(self, color_saturation: float) -> bool: """Set Color Saturation of Bulb in pct (1 - 100).""" return await self.set_status(color_saturation=color_saturation) async def set_color_value(self, color_value: float) -> bool: """Set Value of multicolor bulb in pct (1 - 100).""" # Equivalent to brightness level, when in color mode. return await self.set_status(color_value=color_value) async def set_color_mode(self, color_mode: str) -> bool: """Set Color Mode of Bulb (white / hsv).""" return await self.set_status(color_mode=color_mode) async def set_hsv(self, hue: float, saturation: float, value: float) -> bool: new_color = Color.from_hsv(hue=hue, saturation=saturation, value=value) if new_color is None: logger.warning('Invalid HSV values') return False # the api expects the hsv Value in the brightness parameter payload_data = self._build_status_payload( hue=hue, saturation=saturation, value=value, ) if payload_data is None: return False resp = await self._call_valceno_api('setLightStatusV2', payload_data) if resp is None: return False r_dict = Helpers.process_dev_response(logger, 'set_hsv', self, resp) if r_dict is None: return False status = bulb_models.ResponseValcenoStatus.from_dict(r_dict) self._interpret_apicall_result(status) return True async def set_white_mode(self) -> bool: return await self.set_status(color_mode='white') async def set_status( self, *, brightness: float | None = None, color_temp: float | None = None, color_hue: float | None = None, color_saturation: float | None = None, color_value: float | None = None, color_mode: str | None = None, ) -> bool: """Set multicolor bulb parameters. No arguments turns bulb on. **Kwargs only** Args: brightness (int, optional): brightness between 0 and 100 color_temp (int, optional): color temperature between 0 and 100 color_mode (int, optional): color mode hsv or white color_hue (float, optional): color hue between 0 and 360 color_saturation (float, optional): color saturation between 0 and 100 color_value (int, optional): color value between 0 and 100 Returns: bool : True if call was successful, False otherwise """ payload_data = self._build_status_payload( brightness=brightness, color_temp=color_temp, hue=color_hue, saturation=color_saturation, value=color_value, color_mode=color_mode, ) if payload_data == self._payload_base(): logger.debug('No state change.') return False if payload_data is None: logger.debug('Invalid payload data') return False r_dict = await self._call_valceno_api('setLightStatusV2', payload_data) if r_dict is None: return False r_dict = Helpers.process_dev_response(logger, 'set_status', self, r_dict) if r_dict is None: return False r_model = bulb_models.ResponseValcenoStatus.from_dict(r_dict) self._interpret_apicall_result(r_model) return True def _build_color_payload( self, color_hue: float | None = None, color_saturation: float | None = None, color_value: float | None = None, ) -> bulb_models.ValcenoStatusPayload | None: """Create color payload for Valceno Bulbs. This is called by `_build_status_payload` if any of the HSV values are set. This should not be called directly. Args: color_hue (float, optional): Color hue of bulb (0-360). color_saturation (float, optional): Color saturation of bulb (0-100). color_value (float, optional): Color value of bulb (0-100). """ payload_dict = self._payload_base() new_color = Color.from_hsv( hue=color_hue, saturation=color_saturation, value=color_value ) if new_color is None: logger.warning('Invalid HSV values') return None payload_dict['hue'] = int(new_color.hsv.hue * 250 / 9) payload_dict['saturation'] = int(new_color.hsv.saturation * 100) payload_dict['value'] = int(new_color.hsv.value) payload_dict['colorMode'] = 'hsv' payload_dict['force'] = 1 return payload_dict def _build_status_payload( self, brightness: float | None = None, color_temp: float | None = None, hue: float | None = None, saturation: float | None = None, value: float | None = None, color_mode: str | None = None, ) -> bulb_models.ValcenoStatusPayload | None: """Create status payload data for Valceno Bulbs. If color_mode is set, hue, saturation and/or value must be set as well. If color_mode is not set, brightness and/or color_temp must be set. This builds the `request_body['payload']['data']` dict for api calls that set teh status of the bulb. Args: brightness (float, optional): Brightness of bulb (0-100). color_temp (float, optional): Color temperature of bulb (0-100). hue (float, optional): Color hue of bulb (0-360). saturation (float, optional): Color saturation of bulb (0-100). value (float, optional): Color value of bulb (0-100). color_mode (str, optional): Color mode of bulb ('white', 'hsv', 'color'). Returns: dict | None: Payload dictionary to be sent to the API. """ payload_dict = self._payload_base() if all(itm is not None for itm in [hue, saturation, value]): color_dict = self._build_color_payload( color_hue=hue, color_saturation=saturation, color_value=value ) if color_dict is not None: return color_dict # Return None if HSV values are same as current state if self._check_color_state(hue=hue, saturation=saturation, value=value): return None elif color_mode in ['color', 'hsv', 'rgb']: logger.debug('HSV values must be provided when setting color mode.') else: if color_mode == 'white' and not Validators.validate_zero_to_hundred( color_temp ): payload_dict['colorMode'] = 'white' if color_temp is not None and Validators.validate_zero_to_hundred(color_temp): payload_dict['colorTemp'] = int(color_temp) payload_dict['colorMode'] = 'white' if brightness is not None and Validators.validate_zero_to_hundred(brightness): payload_dict['brightness'] = int(brightness) force_keys = ['colorTemp', 'saturation', 'hue', 'colorMode', 'value'] for key in force_keys: if payload_dict.get(key) != '': payload_dict['force'] = 1 return payload_dict def _check_color_state( self, hue: float | None = None, saturation: float | None = None, value: float | None = None, ) -> bool: """Check if color state is already set. Returns True if color is already set, False otherwise. Args: hue (float, optional): Color hue. saturation (float, optional): Color saturation. value (float, optional): Color value. """ if self.state.color is not None: set_hue = hue or self.state.color.hsv.hue set_saturation = saturation or self.state.color.hsv.saturation set_value = value or self.state.color.hsv.value set_color = Color.from_hsv( hue=set_hue, saturation=set_saturation, value=set_value ) if ( self.state.device_status == DeviceStatus.ON and set_color == self.state.color ): logger.debug('Device already set to requested color') return True return False webdjoe-pyvesync-eb8cecb/src/pyvesync/devices/vesyncfan.py000066400000000000000000000222321507433633000243100ustar00rootroot00000000000000"""Module for VeSync Fans (Not Purifiers or Humidifiers).""" from __future__ import annotations import logging from typing import TYPE_CHECKING from pyvesync.base_devices import VeSyncFanBase from pyvesync.const import ConnectionStatus, DeviceStatus from pyvesync.models.bypass_models import TimerModels from pyvesync.models.fan_models import TowerFanResult from pyvesync.utils.device_mixins import BypassV2Mixin, process_bypassv2_result from pyvesync.utils.helpers import Helpers, Timer if TYPE_CHECKING: from pyvesync import VeSync from pyvesync.device_map import FanMap from pyvesync.models.vesync_models import ResponseDeviceDetailsModel logger = logging.getLogger(__name__) class VeSyncTowerFan(BypassV2Mixin, VeSyncFanBase): """Levoit Tower Fan Device Class.""" __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: FanMap, ) -> None: """Initialize the VeSync Base API V2 Fan Class.""" super().__init__(details, manager, feature_map) def _set_fan_state(self, res: TowerFanResult) -> None: """Set fan state attributes from API response.""" self.state.connection_status = ConnectionStatus.ONLINE self.state.device_status = DeviceStatus.from_int(res.powerSwitch) self.state.mode = res.workMode self.state.fan_level = res.fanSpeedLevel self.state.fan_set_level = res.manualSpeedLevel self.state.temperature = res.temperature self.state.mute_status = DeviceStatus.from_int(res.muteState) self.state.mute_set_status = DeviceStatus.from_int(res.muteSwitch) self.state.oscillation_status = DeviceStatus.from_int(res.oscillationState) self.state.oscillation_set_status = DeviceStatus.from_int(res.oscillationSwitch) self.state.display_status = DeviceStatus.from_int(res.screenState) self.state.display_set_status = DeviceStatus.from_int(res.screenSwitch) self.state.displaying_type = DeviceStatus.from_int(res.displayingType) if res.timerRemain is not None and res.timerRemain > 0: if self.state.device_status == DeviceStatus.ON: self.state.timer = Timer(res.timerRemain, action='off') else: self.state.timer = Timer(res.timerRemain, action='on') if res.sleepPreference is None: return self.state.sleep_preference_type = res.sleepPreference.sleepPreferenceType self.state.sleep_fallasleep_remain = DeviceStatus.from_int( res.sleepPreference.fallAsleepRemain ) self.state.sleep_oscillation_switch = DeviceStatus.from_int( res.sleepPreference.oscillationSwitch ) self.state.sleep_change_fan_level = DeviceStatus.from_int( res.sleepPreference.autoChangeFanLevelSwitch ) async def get_details(self) -> None: r_dict = await self.call_bypassv2_api('getTowerFanStatus') result = process_bypassv2_result( self, logger, 'get_details', r_dict, TowerFanResult ) if result is None: return self._set_fan_state(result) async def set_fan_speed(self, speed: int | None = None) -> bool: if speed is None: new_speed = Helpers.bump_level(self.state.fan_level, self.fan_levels) else: new_speed = speed if new_speed not in self.fan_levels: logger.debug('Invalid fan speed level used - %s', speed) return False payload_data = {'manualSpeedLevel': speed, 'levelType': 'wind', 'levelIdx': 0} r_dict = await self.call_bypassv2_api('setLevel', payload_data) r = Helpers.process_dev_response(logger, 'set_fan_speed', self, r_dict) if r is None: return False self.state.fan_level = speed self.state.connection_status = ConnectionStatus.ONLINE return True async def set_mode(self, mode: str) -> bool: if mode.lower() == DeviceStatus.OFF: logger.warning('Deprecated - Use `turn_off` method to turn off device') return await self.turn_off() if mode.lower() not in self.modes: logger.debug('Invalid purifier mode used - %s', mode) return False payload_data = {'workMode': mode} r_dict = await self.call_bypassv2_api('setTowerFanMode', payload_data) r = Helpers.process_dev_response(logger, 'set_mode', self, r_dict) if r is None: return False self.state.mode = mode self.state.connection_status = ConnectionStatus.ONLINE return True async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.device_status == DeviceStatus.OFF payload_data = { 'powerSwitch': int(toggle), 'switchIdx': 0, } r_dict = await self.call_bypassv2_api('setSwitch', payload_data) r = Helpers.process_dev_response(logger, 'toggle_switch', self, r_dict) if r is None: return False self.state.device_status = DeviceStatus.from_bool(toggle) self.state.connection_status = ConnectionStatus.ONLINE return True async def toggle_oscillation(self, toggle: bool) -> bool: payload_data = {'oscillationSwitch': int(toggle)} r_dict = await self.call_bypassv2_api('setOscillationSwitch', payload_data) r = Helpers.process_dev_response(logger, 'toggle_oscillation', self, r_dict) if r is None: return False self.state.oscillation_status = DeviceStatus.from_bool(toggle) self.state.connection_status = ConnectionStatus.ONLINE return True async def toggle_mute(self, toggle: bool) -> bool: payload_data = {'muteSwitch': int(toggle)} r_dict = await self.call_bypassv2_api('setMuteSwitch', payload_data) r = Helpers.process_dev_response(logger, 'toggle_mute', self, r_dict) if r is None: return False self.state.mute_status = DeviceStatus.from_bool(toggle) self.state.connection_status = ConnectionStatus.ONLINE return True async def toggle_display(self, toggle: bool) -> bool: payload_data = {'screenSwitch': int(toggle)} r_dict = await self.call_bypassv2_api('setDisplay', payload_data) r = Helpers.process_dev_response(logger, 'toggle_display', self, r_dict) if r is None: return False self.state.display_status = DeviceStatus.from_bool(toggle) self.state.connection_status = ConnectionStatus.ONLINE return True async def toggle_displaying_type(self, toggle: bool) -> bool: """Set displaying type on/off - Unknown functionality.""" payload_data = {'displayingType': int(toggle)} r_dict = await self.call_bypassv2_api('setDisplayingType', payload_data) r = Helpers.process_dev_response(logger, 'toggle_displaying_type', self, r_dict) if r is None: return False self.state.displaying_type = DeviceStatus.from_bool(toggle) self.state.connection_status = ConnectionStatus.ONLINE return True async def get_timer(self) -> None: r_dict = await self.call_bypassv2_api('getTimer', {}) r_model = process_bypassv2_result( self, logger, 'get_timer', r_dict, TimerModels.ResultV2GetTimer ) if not r_model: logger.debug('No timer found - run get_timer()') return if r_model.timers is None or len(r_model.timers) == 0: logger.debug('No timer found - run get_timer()') return timer = r_model.timers[0] if not isinstance(timer, TimerModels.TimerItemV2): logger.debug('Invalid timer found for %s', self.device_name) return self.state.timer = Timer(timer.remain, timer.action, timer.id) async def set_timer(self, duration: int, action: str | None = None) -> bool: if action is None: action = ( DeviceStatus.OFF if self.state.device_status == DeviceStatus.ON else DeviceStatus.ON ) if action not in [DeviceStatus.ON, DeviceStatus.OFF]: logger.debug('Invalid action used - %s', action) return False payload_data = { 'action': action, 'total': duration, } r_dict = await self.call_bypassv2_api('setTimer', payload_data) r_model = process_bypassv2_result( self, logger, 'set_timer', r_dict, TimerModels.ResultV2SetTimer ) if r_model is None: return False self.state.timer = Timer(duration, action, r_model.id) return True async def clear_timer(self) -> bool: if self.state.timer is None: logger.debug('No timer found for - run get_timer()') return False payload_data = { 'id': self.state.timer.id, } r_dict = await self.call_bypassv2_api('clearTimer', payload_data) result = Helpers.process_dev_response(logger, 'clear_timer', self, r_dict) if result is None: return False self.state.timer = None return True webdjoe-pyvesync-eb8cecb/src/pyvesync/devices/vesynchumidifier.py000066400000000000000000001003131507433633000256660ustar00rootroot00000000000000"""VeSync Humidifier Devices.""" from __future__ import annotations import logging from typing import TYPE_CHECKING import orjson from typing_extensions import deprecated from pyvesync.base_devices import VeSyncHumidifier from pyvesync.const import ConnectionStatus, DeviceStatus from pyvesync.models.bypass_models import ResultV2GetTimer, ResultV2SetTimer from pyvesync.models.humidifier_models import ( ClassicLVHumidResult, InnerHumidifierBaseResult, Levoit1000SResult, Superior6000SResult, ) from pyvesync.utils.device_mixins import BypassV2Mixin, process_bypassv2_result from pyvesync.utils.helpers import Helpers, Timer, Validators if TYPE_CHECKING: from pyvesync import VeSync from pyvesync.device_map import HumidifierMap from pyvesync.models.vesync_models import ResponseDeviceDetailsModel logger = logging.getLogger(__name__) class VeSyncHumid200300S(BypassV2Mixin, VeSyncHumidifier): """300S Humidifier Class. Primary class for VeSync humidifier devices. Args: details (ResponseDeviceDetailsModel): The device details. manager (VeSync): The manager object for API calls. feature_map (HumidifierMap): The feature map for the device. Attributes: state (HumidifierState): The state of the humidifier. last_response (ResponseInfo): Last response info from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. mist_levels (list): List of mist levels. mist_modes (list): List of mist modes. target_minmax (tuple): Tuple of target min and max values. warm_mist_levels (list): List of warm mist levels. """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: HumidifierMap, ) -> None: """Initialize 200S/300S Humidifier class.""" super().__init__(details, manager, feature_map) def _set_state(self, resp_model: ClassicLVHumidResult) -> None: """Set state from get_details API model.""" self.state.connection_status = ConnectionStatus.ONLINE self.state.device_status = DeviceStatus.from_bool(resp_model.enabled) self.state.mode = resp_model.mode self.state.humidity = resp_model.humidity self.state.mist_virtual_level = resp_model.mist_virtual_level or 0 self.state.mist_level = resp_model.mist_level or 0 self.state.water_lacks = resp_model.water_lacks self.state.humidity_high = resp_model.humidity_high self.state.water_tank_lifted = resp_model.water_tank_lifted self.state.auto_stop_target_reached = resp_model.automatic_stop_reach_target if self.supports_nightlight and resp_model.night_light_brightness is not None: self.state.nightlight_brightness = resp_model.night_light_brightness self.state.nightlight_status = ( DeviceStatus.ON if resp_model.night_light_brightness > 0 else DeviceStatus.OFF ) self.state.display_status = DeviceStatus.from_bool(resp_model.display) if self.supports_warm_mist and resp_model.warm_level is not None: self.state.warm_mist_level = resp_model.warm_level self.state.warm_mist_enabled = resp_model.warm_enabled config = resp_model.configuration if config is not None: self.state.auto_target_humidity = config.auto_target_humidity self.state.automatic_stop_config = config.automatic_stop self.state.display_set_status = DeviceStatus.from_bool(config.display) async def get_details(self) -> None: r_dict = await self.call_bypassv2_api('getHumidifierStatus') r_model = process_bypassv2_result( self, logger, 'get_details', r_dict, ClassicLVHumidResult ) if r_model is None: return self._set_state(r_model) async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.device_status == DeviceStatus.ON payload_data = {'enabled': toggle, 'id': 0} r_dict = await self.call_bypassv2_api('setSwitch', payload_data) r = Helpers.process_dev_response(logger, 'toggle_switch', self, r_dict) if r is None: return False self.state.device_status = DeviceStatus.from_bool(toggle) self.state.connection_status = ConnectionStatus.ONLINE return True async def get_timer(self) -> Timer | None: r_dict = await self.call_bypassv2_api('getTimer') result_model = process_bypassv2_result( self, logger, 'get_timer', r_dict, ResultV2GetTimer ) if result_model is None: return None if not result_model.timers: logger.debug('No timers found') return None timer = result_model.timers[0] self.state.timer = Timer( timer_duration=timer.total, action=timer.action, id=timer.id, ) return self.state.timer async def clear_timer(self) -> bool: if self.state.timer is None: logger.debug('No timer to clear, run get_timer() first.') return False payload = { 'id': self.state.timer.id, } r_dict = await self.call_bypassv2_api('delTimer', payload) r = Helpers.process_dev_response(logger, 'clear_timer', self, r_dict) if r is None: return False self.state.timer = None return True async def set_timer(self, duration: int, action: str | None = None) -> bool: if action is None: action = ( DeviceStatus.OFF if self.state.device_status == DeviceStatus.ON else DeviceStatus.ON ) payload_data = { 'action': str(action), 'total': duration, } r_dict = await self.call_bypassv2_api('addTimer', payload_data) r = process_bypassv2_result(self, logger, 'set_timer', r_dict, ResultV2SetTimer) if r is None: return False self.state.timer = Timer( timer_duration=duration, action=action, id=r.id, remaining=0 ) return True @deprecated('Use turn_on_automatic_stop() instead.') async def automatic_stop_on(self) -> bool: """Turn 200S/300S Humidifier automatic stop on.""" return await self.toggle_automatic_stop(True) @deprecated('Use turn_off_automatic_stop() instead.') async def automatic_stop_off(self) -> bool: """Turn 200S/300S Humidifier automatic stop on.""" return await self.toggle_automatic_stop(False) @deprecated('Use toggle_automatic_stop(toggle: bool) instead.') async def set_automatic_stop(self, mode: bool) -> bool: """Set 200S/300S Humidifier to automatic stop.""" return await self.toggle_automatic_stop(mode) async def toggle_automatic_stop(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.automatic_stop_config != DeviceStatus.ON payload_data = {'enabled': toggle} r_dict = await self.call_bypassv2_api('setAutomaticStop', payload_data) r = Helpers.process_dev_response(logger, 'set_automatic_stop', self, r_dict) if r is None: return False self.state.automatic_stop_config = toggle self.state.connection_status = ConnectionStatus.ONLINE return True @deprecated('Use toggle_display(toggle: bool) instead.') async def set_display(self, toggle: bool) -> bool: """Deprecated method to toggle display on/off. Use toggle_display(toggle: bool) instead. """ return await self.toggle_display(toggle) async def toggle_display(self, toggle: bool) -> bool: payload_data = {'state': toggle} r_dict = await self.call_bypassv2_api('setDisplay', payload_data) r = Helpers.process_dev_response(logger, 'set_display', self, r_dict) if r is None: return False self.state.display_set_status = DeviceStatus.from_bool(toggle) self.state.display_status = DeviceStatus.from_bool(toggle) self.state.connection_status = ConnectionStatus.ONLINE return True async def set_humidity(self, humidity: int) -> bool: if not Validators.validate_range(humidity, *self.target_minmax): logger.debug( 'Invalid humidity, must be between %s and %s', *self.target_minmax ) return False payload_data = {'target_humidity': humidity} r_dict = await self.call_bypassv2_api('setTargetHumidity', payload_data) r = Helpers.process_dev_response(logger, 'set_humidity', self, r_dict) if r is None: return False self.state.auto_target_humidity = humidity self.state.connection_status = ConnectionStatus.ONLINE return True async def set_nightlight_brightness(self, brightness: int) -> bool: if not self.supports_nightlight: logger.debug( '%s is a %s does not have a nightlight', self.device_name, self.device_type, ) return False if not Validators.validate_zero_to_hundred(brightness): logger.debug('Brightness value must be set between 0 and 100') return False payload_data = {'night_light_brightness': brightness} r_dict = await self.call_bypassv2_api('setNightLightBrightness', payload_data) r = Helpers.process_dev_response( logger, 'set_night_light_brightness', self, r_dict ) if r is None: return False self.state.nightlight_brightness = brightness self.state.nightlight_status = ( DeviceStatus.ON if brightness > 0 else DeviceStatus.OFF ) return True @deprecated('Use set_mode(mode: str) instead.') async def set_humidity_mode(self, mode: str) -> bool: """Deprecated - set humidifier mode. Use `set_mode(mode: str)` instead. """ return await self.set_mode(mode) async def set_mode(self, mode: str) -> bool: if mode.lower() not in self.mist_modes: logger.debug('Invalid humidity mode used - %s', mode) logger.debug( 'Proper modes for this device are - %s', orjson.dumps( self.mist_modes, option=orjson.OPT_INDENT_2 | orjson.OPT_NON_STR_KEYS ), ) return False payload_data = {'mode': self.mist_modes[mode.lower()]} r_dict = await self.call_bypassv2_api('setHumidityMode', payload_data) r = Helpers.process_dev_response(logger, 'set_humidity_mode', self, r_dict) if r is None: return False self.state.mode = mode self.state.connection_status = ConnectionStatus.ONLINE return True async def set_warm_level(self, warm_level: int) -> bool: if not self.supports_warm_mist: logger.debug( '%s is a %s does not have a mist warmer', self.device_name, self.device_type, ) return False if warm_level not in self.warm_mist_levels: logger.debug('warm_level value must be - %s', str(self.warm_mist_levels)) return False payload_data = {'type': 'warm', 'level': warm_level, 'id': 0} r_dict = await self.call_bypassv2_api('setVirtualLevel', payload_data) r = Helpers.process_dev_response(logger, 'set_warm_level', self, r_dict) if r is None: return False self.state.warm_mist_level = warm_level self.state.warm_mist_enabled = True self.state.connection_status = ConnectionStatus.ONLINE return True async def set_mist_level(self, level: int) -> bool: if level not in self.mist_levels: logger.debug( 'Humidifier mist level must be between %s and %s', self.mist_levels[0], self.mist_levels[-1], ) return False payload_data = {'id': 0, 'level': level, 'type': 'mist'} r_dict = await self.call_bypassv2_api('setVirtualLevel', payload_data) r = Helpers.process_dev_response(logger, 'set_mist_level', self, r_dict) if r is None: return False self.state.mist_virtual_level = level self.state.mist_level = level self.state.connection_status = ConnectionStatus.ONLINE return True class VeSyncHumid200S(VeSyncHumid200300S): """Levoit Classic 200S Specific class. Overrides the `toggle_display(toggle: bool)` method of the VeSyncHumid200300S class. Args: details (ResponseDeviceDetailsModel): The device details. manager (VeSync): The manager object for API calls. feature_map (HumidifierMap): The feature map for the device. Attributes: state (HumidifierState): The state of the humidifier. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. mist_levels (list): List of mist levels. mist_modes (list): List of mist modes. target_minmax (tuple): Tuple of target min and max values. warm_mist_levels (list): List of warm mist levels. """ def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: HumidifierMap, ) -> None: """Initialize levoit 200S device class. This overrides the `toggle_display(toggle: bool)` method of the VeSyncHumid200300S class. """ super().__init__(details, manager, feature_map) async def toggle_display(self, toggle: bool) -> bool: payload_data = {'enabled': toggle, 'id': 0} r_dict = await self.call_bypassv2_api('setIndicatorLightSwitch', payload_data) r = Helpers.process_dev_response(logger, 'toggle_display', self, r_dict) if r is None: return False self.state.display_set_status = DeviceStatus.from_bool(toggle) self.state.display_status = DeviceStatus.from_bool(toggle) self.state.connection_status = ConnectionStatus.ONLINE return True class VeSyncSuperior6000S(BypassV2Mixin, VeSyncHumidifier): """Superior 6000S Humidifier. Args: details (ResponseDeviceDetailsModel): The device details. manager (VeSync): The manager object for API calls. feature_map (HumidifierMap): The feature map for the device. Attributes: state (HumidifierState): The state of the humidifier. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. mist_levels (list): List of mist levels. mist_modes (list): List of mist modes. target_minmax (tuple): Tuple of target min and max values. warm_mist_levels (list): List of warm mist levels. """ def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: HumidifierMap, ) -> None: """Initialize Superior 6000S Humidifier class.""" super().__init__(details, manager, feature_map) def _set_state(self, resp_model: Superior6000SResult) -> None: """Set state from Superior 6000S API result model.""" self.state.device_status = DeviceStatus.from_int(resp_model.powerSwitch) self.state.connection_status = ConnectionStatus.ONLINE self.state.mode = resp_model.workMode self.state.auto_target_humidity = resp_model.targetHumidity self.state.mist_level = resp_model.mistLevel self.state.mist_virtual_level = resp_model.virtualLevel self.state.water_lacks = bool(resp_model.waterLacksState) self.state.water_tank_lifted = bool(resp_model.waterTankLifted) self.state.automatic_stop_config = bool(resp_model.autoStopSwitch) self.state.auto_stop_target_reached = bool(resp_model.autoStopState) self.state.display_set_status = DeviceStatus.from_int(resp_model.screenSwitch) self.state.display_status = DeviceStatus.from_int(resp_model.screenState) self.state.auto_preference = resp_model.autoPreference self.state.filter_life_percent = resp_model.filterLifePercent self.state.temperature = resp_model.temperature # Unknown units drying_mode = resp_model.dryingMode if drying_mode is not None: self.state.drying_mode_status = DeviceStatus.from_int(drying_mode.dryingState) self.state.drying_mode_level = drying_mode.dryingLevel self.state.drying_mode_auto_switch = DeviceStatus.from_int( drying_mode.autoDryingSwitch ) if resp_model.timerRemain > 0: self.state.timer = Timer( resp_model.timerRemain, DeviceStatus.from_bool(self.state.device_status != DeviceStatus.ON), ) async def get_details(self) -> None: r_dict = await self.call_bypassv2_api('getHumidifierStatus') r_model = process_bypassv2_result( self, logger, 'get_details', r_dict, Superior6000SResult ) if r_model is None: return self._set_state(r_model) async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.device_status != DeviceStatus.ON payload_data = {'powerSwitch': int(toggle), 'switchIdx': 0} r_dict = await self.call_bypassv2_api('setSwitch', payload_data) r = Helpers.process_dev_response(logger, 'toggle_switch', self, r_dict) if r is None: return False self.state.device_status = DeviceStatus.from_bool(toggle) self.state.connection_status = ConnectionStatus.ONLINE return True async def toggle_automatic_stop(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.automatic_stop_config is not True payload_data = {'autoStopSwitch': int(toggle)} r_dict = await self.call_bypassv2_api('setAutoStopSwitch', payload_data) r = Helpers.process_dev_response(logger, 'toggle_automatic_stop', self, r_dict) if r is None: return False self.state.device_status = DeviceStatus.from_bool(toggle) self.state.connection_status = ConnectionStatus.ONLINE return True @deprecated('Use toggle_drying_mode() instead.') async def set_drying_mode_enabled(self, mode: bool) -> bool: """Set drying mode on/off.""" return await self.toggle_drying_mode(mode) async def toggle_drying_mode(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.drying_mode_status != DeviceStatus.ON payload_data = {'autoDryingSwitch': int(toggle)} r_dict = await self.call_bypassv2_api('setDryingMode', payload_data) r = Helpers.process_dev_response(logger, 'set_drying_mode_enabled', self, r_dict) if r is None: return False self.state.connection_status = ConnectionStatus.ONLINE self.state.drying_mode_auto_switch = DeviceStatus.from_bool(toggle) return True @deprecated('Use toggle_display() instead.') async def set_display_enabled(self, mode: bool) -> bool: """Set display on/off. Deprecated method, please use toggle_display() instead. """ return await self.toggle_display(mode) async def toggle_display(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.display_set_status != DeviceStatus.ON payload_data = {'screenSwitch': int(toggle)} r_dict = await self.call_bypassv2_api('setDisplay', payload_data) r = Helpers.process_dev_response(logger, 'set_display', self, r_dict) if r is None: return False self.state.display_set_status = DeviceStatus.from_bool(toggle) self.state.connection_status = ConnectionStatus.ONLINE return True async def set_humidity(self, humidity: int) -> bool: if not Validators.validate_range(humidity, *self.target_minmax): logger.debug('Humidity value must be set between 30 and 80') return False payload_data = {'targetHumidity': humidity} r_dict = await self.call_bypassv2_api('setTargetHumidity', payload_data) r = Helpers.process_dev_response(logger, 'set_humidity', self, r_dict) if r is None: return False self.state.auto_target_humidity = humidity self.state.connection_status = ConnectionStatus.ONLINE return True @deprecated('Use set_mode(mode: str) instead.') async def set_humidity_mode(self, mode: str) -> bool: """Set humidifier mode.""" return await self.set_mode(mode) async def set_mode(self, mode: str) -> bool: if mode.lower() not in self.mist_modes: logger.debug('Invalid humidity mode used - %s', mode) logger.debug( 'Proper modes for this device are - %s', orjson.dumps( self.mist_modes, option=orjson.OPT_INDENT_2 | orjson.OPT_NON_STR_KEYS ), ) return False payload_data = {'workMode': self.mist_modes[mode.lower()]} r_dict = await self.call_bypassv2_api('setHumidityMode', payload_data) r = Helpers.process_dev_response(logger, 'set_humidity_mode', self, r_dict) if r is None: return False self.state.mode = mode self.state.connection_status = ConnectionStatus.ONLINE return True async def set_mist_level(self, level: int) -> bool: if level not in self.mist_levels: logger.debug('Humidifier mist level must be between 0 and 9') return False payload_data = {'levelIdx': 0, 'virtualLevel': level, 'levelType': 'mist'} r_dict = await self.call_bypassv2_api('setVirtualLevel', payload_data) r = Helpers.process_dev_response(logger, 'set_mist_level', self, r_dict) if r is None: return False self.state.mist_level = level self.state.mist_virtual_level = level self.state.connection_status = ConnectionStatus.ONLINE return True class VeSyncHumid1000S(VeSyncHumid200300S): """Levoit OasisMist 1000S Specific class. Args: details (ResponseDeviceDetailsModel): The device details. manager (VeSync): The manager object for API calls. feature_map (HumidifierMap): The feature map for the device. Attributes: state (HumidifierState): The state of the humidifier. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. mist_levels (list): List of mist levels. mist_modes (list): List of mist modes. target_minmax (tuple): Tuple of target min and max values. warm_mist_levels (list): List of warm mist levels. """ def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: HumidifierMap, ) -> None: """Initialize levoit 1000S device class.""" super().__init__(details, manager, feature_map) def _set_state(self, resp_model: InnerHumidifierBaseResult) -> None: """Set state of Levoit 1000S from API result model.""" if not isinstance(resp_model, Levoit1000SResult): return self.state.device_status = DeviceStatus.from_int(resp_model.powerSwitch) self.state.connection_status = ConnectionStatus.ONLINE self.state.mode = resp_model.workMode self.state.humidity = resp_model.humidity self.state.auto_target_humidity = resp_model.targetHumidity self.state.mist_level = resp_model.mistLevel self.state.mist_virtual_level = resp_model.virtualLevel self.state.water_lacks = bool(resp_model.waterLacksState) self.state.water_tank_lifted = bool(resp_model.waterTankLifted) self.state.automatic_stop_config = bool(resp_model.autoStopSwitch) self.state.auto_stop_target_reached = bool(resp_model.autoStopState) self.state.display_set_status = DeviceStatus.from_int(resp_model.screenSwitch) self.state.display_status = DeviceStatus.from_int(resp_model.screenState) async def get_details(self) -> None: r_dict = await self.call_bypassv2_api('getHumidifierStatus') r_model = process_bypassv2_result( self, logger, 'get_details', r_dict, Levoit1000SResult ) if r_model is None: return self._set_state(r_model) @deprecated('Use toggle_display() instead.') async def set_display(self, toggle: bool) -> bool: """Toggle display on/off. This is a deprecated method, please use toggle_display() instead. """ return await self.toggle_switch(toggle) async def toggle_display(self, toggle: bool) -> bool: payload_data = {'screenSwitch': int(toggle)} body = self._build_request('setDisplay', payload_data) headers = Helpers.req_header_bypass() r_dict, _ = await self.manager.async_call_api( '/cloud/v2/deviceManaged/bypassV2', method='post', headers=headers, json_object=body.to_dict(), ) r = Helpers.process_dev_response(logger, 'set_display', self, r_dict) if r is None: return False self.state.display_set_status = DeviceStatus.from_bool(toggle) self.state.connection_status = ConnectionStatus.ONLINE return True @deprecated('Use set_mode() instead.') async def set_humidity_mode(self, mode: str) -> bool: """Set humidifier mode - sleep, auto or manual. Deprecated, please use set_mode() instead. """ return await self.set_mode(mode) async def set_mode(self, mode: str) -> bool: if mode.lower() not in self.mist_modes: logger.debug('Invalid humidity mode used - %s', mode) logger.debug( 'Proper modes for this device are - %s', orjson.dumps( self.mist_modes, option=orjson.OPT_INDENT_2 | orjson.OPT_NON_STR_KEYS ), ) return False payload_data = {'workMode': mode.lower()} r_dict = await self.call_bypassv2_api('setHumidityMode', payload_data) r = Helpers.process_dev_response(logger, 'set_mode', self, r_dict) if r is None: return False self.state.mode = mode self.state.connection_status = ConnectionStatus.ONLINE return True async def set_mist_level(self, level: int) -> bool: if level not in self.mist_levels: logger.debug('Humidifier mist level out of range') return False payload_data = {'levelIdx': 0, 'virtualLevel': level, 'levelType': 'mist'} r_dict = await self.call_bypassv2_api('virtualLevel', payload_data) r = Helpers.process_dev_response(logger, 'set_mist_level', self, r_dict) if r is None: return False self.state.mist_level = level self.state.mist_virtual_level = level self.state.connection_status = ConnectionStatus.ONLINE return True async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.device_status != DeviceStatus.ON payload_data = {'powerSwitch': int(toggle), 'switchIdx': 0} r_dict = await self.call_bypassv2_api('setSwitch', payload_data) r = Helpers.process_dev_response(logger, 'toggle_switch', self, r_dict) if r is None: return False self.state.device_status = DeviceStatus.from_bool(toggle) self.state.connection_status = ConnectionStatus.ONLINE return True async def set_humidity(self, humidity: int) -> bool: if not Validators.validate_range(humidity, *self.target_minmax): logger.debug( 'Humidity value must be set between %s and %s', self.target_minmax[0], self.target_minmax[1], ) return False payload_data = {'targetHumidity': humidity} r_dict = await self.call_bypassv2_api('setTargetHumidity', payload_data) r = Helpers.process_dev_response(logger, 'set_humidity', self, r_dict) if r is None: return False self.state.auto_target_humidity = humidity self.state.connection_status = ConnectionStatus.ONLINE return True @deprecated('Use toggle_automatic_stop() instead.') async def set_automatic_stop(self, mode: bool) -> bool: return await self.toggle_automatic_stop(mode) async def toggle_automatic_stop(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.automatic_stop_config != DeviceStatus.ON payload_data = {'autoStopSwitch': int(toggle)} r_dict = await self.call_bypassv2_api('setAutoStopSwitch', payload_data) r = Helpers.process_dev_response(logger, 'set_automatic_stop', self, r_dict) if r is None: return False self.state.automatic_stop_config = toggle self.state.connection_status = ConnectionStatus.ONLINE return True webdjoe-pyvesync-eb8cecb/src/pyvesync/devices/vesynckitchen.py000066400000000000000000000574151507433633000252040ustar00rootroot00000000000000"""VeSync Kitchen Devices. The Cosori 3.7 and 5.8 Quart Air Fryer has several methods and properties that can be used to monitor and control the device. To maintain consistency of state, the update() method is called after each of the methods that change the state of the device. There is also an instance attribute that can be set `VeSyncAirFryer158.refresh_interval` that will set the interval in seconds that the state of the air fryer should be updated before a method that changes state is called. This is an additional API call but is necessary to maintain state, especially when trying to `pause` or `resume` the device. Defaults to 60 seconds but can be set via: ```python # Change to 120 seconds before status is updated between calls VeSyncAirFryer158.refresh_interval = 120 # Set status update before every call VeSyncAirFryer158.refresh_interval = 0 # Disable status update before every call VeSyncAirFryer158.refresh_interval = -1 ``` """ from __future__ import annotations import logging import time from typing import TYPE_CHECKING, TypeVar from typing_extensions import deprecated from pyvesync.base_devices import FryerState, VeSyncFryer from pyvesync.const import AIRFRYER_PID_MAP, ConnectionStatus, DeviceStatus from pyvesync.models.base_models import DefaultValues from pyvesync.utils.errors import VeSyncError from pyvesync.utils.helpers import Helpers from pyvesync.utils.logs import LibraryLogger if TYPE_CHECKING: from pyvesync import VeSync from pyvesync.device_map import AirFryerMap from pyvesync.models.vesync_models import ResponseDeviceDetailsModel T = TypeVar('T') logger = logging.getLogger(__name__) # Status refresh interval in seconds # API calls outside of interval are automatically refreshed # Set VeSyncAirFryer158.refresh_interval to 0 to refresh every call # Set to None or -1 to disable auto-refresh REFRESH_INTERVAL = 60 RECIPE_ID = 1 RECIPE_TYPE = 3 CUSTOM_RECIPE = 'Manual Cook' COOK_MODE = 'custom' class AirFryer158138State(FryerState): """Dataclass for air fryer status. Attributes: active_time (int): Active time of device, defaults to None. connection_status (str): Connection status of device. device (VeSyncBaseDevice): Device object. device_status (str): Device status. features (dict): Features of device. last_update_ts (int): Last update timestamp of device, defaults to None. ready_start (bool): Ready start status of device, defaults to False. preheat (bool): Preheat status of device, defaults to False. cook_status (str): Cooking status of device, defaults to None. current_temp (int): Current temperature of device, defaults to None. cook_set_temp (int): Cooking set temperature of device, defaults to None. last_timestamp (int): Last timestamp of device, defaults to None. preheat_set_time (int): Preheat set time of device, defaults to None. preheat_last_time (int): Preheat last time of device, defaults to None. _temp_unit (str): Temperature unit of device, defaults to None. """ __slots__ = ( '_temp_unit', 'cook_last_time', 'cook_set_temp', 'cook_set_time', 'cook_status', 'current_temp', 'last_timestamp', 'max_temp_c', 'max_temp_f', 'min_temp_c', 'min_temp_f', 'preheat', 'preheat_last_time', 'preheat_set_time', 'ready_start', ) def __init__( self, device: VeSyncAirFryer158, details: ResponseDeviceDetailsModel, feature_map: AirFryerMap, ) -> None: """Init the Air Fryer 158 class.""" super().__init__(device, details, feature_map) self.device: VeSyncFryer = device self.features: list[str] = feature_map.features self.min_temp_f: int = feature_map.temperature_range_f[0] self.max_temp_f: int = feature_map.temperature_range_f[1] self.min_temp_c: int = feature_map.temperature_range_c[0] self.max_temp_c: int = feature_map.temperature_range_c[1] self.ready_start: bool = False self.preheat: bool = False self.cook_status: str | None = None self.current_temp: int | None = None self.cook_set_temp: int | None = None self.cook_set_time: int | None = None self.cook_last_time: int | None = None self.last_timestamp: int | None = None self.preheat_set_time: int | None = None self.preheat_last_time: int | None = None self._temp_unit: str | None = None @property def is_resumable(self) -> bool: """Return if cook is resumable.""" if self.cook_status in ['cookStop', 'preheatStop']: if self.cook_set_time is not None: return self.cook_set_time > 0 if self.preheat_set_time is not None: return self.preheat_set_time > 0 return False @property def temp_unit(self) -> str | None: """Return temperature unit.""" return self._temp_unit @temp_unit.setter def temp_unit(self, temp_unit: str) -> None: """Set temperature unit.""" if temp_unit.lower() in ['f', 'fahrenheit', 'fahrenheight']: # API TYPO self._temp_unit = 'fahrenheit' elif temp_unit.lower() in ['c', 'celsius']: self._temp_unit = 'celsius' else: msg = f'Invalid temperature unit - {temp_unit}' raise ValueError(msg) @property def preheat_time_remaining(self) -> int: """Return preheat time remaining.""" if self.preheat is False or self.cook_status == 'preheatEnd': return 0 if self.cook_status in ['pullOut', 'preheatStop']: if self.preheat_last_time is None: return 0 return int(self.preheat_last_time // 60) if self.preheat_last_time is not None and self.last_timestamp is not None: return int( max( (self.preheat_last_time - (int(time.time()) - self.last_timestamp)) // 60, 0, ) ) return 0 @property def cook_time_remaining(self) -> int: """Returns the amount of time remaining if cooking.""" if self.preheat is True or self.cook_status == 'cookEnd': return 0 if self.cook_status in ['pullOut', 'cookStop']: if self.cook_last_time is None: return 0 return int(max(self.cook_last_time // 60, 0)) if self.cook_last_time is not None and self.last_timestamp is not None: return int( max( (self.cook_last_time - (int(time.time()) - self.last_timestamp)) // 60, 0, ) ) return 0 @property def remaining_time(self) -> int: """Return minutes remaining if cooking/heating.""" if self.preheat is True: return self.preheat_time_remaining return self.cook_time_remaining @property def is_running(self) -> bool: """Return if cooking or heating.""" return bool(self.cook_status in ['cooking', 'heating']) and bool( self.remaining_time > 0 ) @property def is_cooking(self) -> bool: """Return if cooking.""" return self.cook_status == 'cooking' and self.remaining_time > 0 @property def is_heating(self) -> bool: """Return if heating.""" return self.cook_status == 'heating' and self.remaining_time > 0 def status_request(self, json_cmd: dict) -> None: # noqa: C901 """Set status from jsonCmd of API call.""" self.last_timestamp = None if not isinstance(json_cmd, dict): return self.preheat = False preheat = json_cmd.get('preheat') cook = json_cmd.get('cookMode') if isinstance(preheat, dict): self.preheat = True if preheat.get('preheatStatus') == 'stop': self.cook_status = 'preheatStop' elif preheat.get('preheatStatus') == 'heating': self.cook_status = 'heating' self.last_timestamp = int(time.time()) self.preheat_set_time = preheat.get( 'preheatSetTime', self.preheat_set_time ) if preheat.get('preheatSetTime') is not None: self.preheat_last_time = preheat.get('preheatSetTime') self.cook_set_temp = preheat.get('targetTemp', self.cook_set_temp) self.cook_set_time = preheat.get('cookSetTime', self.cook_set_time) self.cook_last_time = None elif preheat.get('preheatStatus') == 'end': self.cook_status = 'preheatEnd' self.preheat_last_time = 0 elif isinstance(cook, dict): self.clear_preheat() if cook.get('cookStatus') == 'stop': self.cook_status = 'cookStop' elif cook.get('cookStatus') == 'cooking': self.cook_status = 'cooking' self.last_timestamp = int(time.time()) self.cook_set_time = cook.get('cookSetTime', self.cook_set_time) self.cook_set_temp = cook.get('cookSetTemp', self.cook_set_temp) self.current_temp = cook.get('currentTemp', self.current_temp) self.temp_unit = cook.get( 'tempUnit', self.temp_unit, # type: ignore[assignment] ) elif cook.get('cookStatus') == 'end': self.set_standby() self.cook_status = 'cookEnd' def clear_preheat(self) -> None: """Clear preheat status.""" self.preheat = False self.preheat_set_time = None self.preheat_last_time = None def set_standby(self) -> None: """Clear cooking status.""" self.cook_status = 'standby' self.clear_preheat() self.cook_last_time = None self.current_temp = None self.cook_set_time = None self.cook_set_temp = None self.last_timestamp = None def status_response(self, return_status: dict) -> None: """Set status of Air Fryer Based on API Response.""" self.last_timestamp = None self.preheat = False self.cook_status = return_status.get('cookStatus') if self.cook_status == 'standby': self.set_standby() return # If drawer is pulled out, set standby if resp does not contain other details if self.cook_status == 'pullOut': self.last_timestamp = None if 'currentTemp' not in return_status or 'tempUnit' not in return_status: self.set_standby() self.cook_status = 'pullOut' return if return_status.get('preheatLastTime') is not None or self.cook_status in [ 'heating', 'preheatStop', 'preheatEnd', ]: self.preheat = True self.cook_set_time = return_status.get('cookSetTime', self.cook_set_time) self.cook_last_time = return_status.get('cookLastTime') self.current_temp = return_status.get('curentTemp') self.cook_set_temp = return_status.get( 'targetTemp', return_status.get('cookSetTemp') ) self.temp_unit = return_status.get( 'tempUnit', self.temp_unit, # type: ignore[assignment] ) self.preheat_set_time = return_status.get('preheatSetTime') self.preheat_last_time = return_status.get('preheatLastTime') # Set last_time timestamp if cooking if self.cook_status in ['cooking', 'heating']: self.last_timestamp = int(time.time()) if self.cook_status == 'preheatEnd': self.preheat_last_time = 0 self.cook_last_time = None if self.cook_status == 'cookEnd': self.cook_last_time = 0 # If Cooking, clear preheat status if self.cook_status in ['cooking', 'cookStop', 'cookEnd']: self.clear_preheat() class VeSyncAirFryer158(VeSyncFryer): """Cosori Air Fryer Class. Args: details (ResponseDeviceDetailsModel): Device details. manager (VeSync): Manager class. feature_map (DeviceMapTemplate): Device feature map. Attributes: features (list[str]): List of features. state (AirFryer158138State): Air fryer state. last_update (int): Last update timestamp. refresh_interval (int): Refresh interval in seconds. cook_temps (dict[str, list[int]] | None): Cook temperatures. pid (str): PID for the device. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. """ __slots__ = ( 'cook_temps', 'fryer_status', 'last_update', 'ready_start', 'refresh_interval', ) def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: AirFryerMap, ) -> None: """Init the VeSync Air Fryer 158 class.""" super().__init__(details, manager, feature_map) self.features: list[str] = feature_map.features self.state: AirFryer158138State = AirFryer158138State(self, details, feature_map) self.last_update: int = int(time.time()) self.refresh_interval = 0 self.ready_start = False self.cook_temps: dict[str, list[int]] | None = None if self.config_module not in AIRFRYER_PID_MAP: msg = ( 'Report this error as an issue - ' f'{self.config_module} not found in PID map for {self}' ) raise VeSyncError(msg) self.pid = AIRFRYER_PID_MAP[self.config_module] self.request_keys = [ 'acceptLanguage', 'accountID', 'appVersion', 'cid', 'configModule', 'deviceRegion', 'phoneBrand', 'phoneOS', 'timeZone', 'token', 'traceId', 'userCountryCode', 'method', 'debugMode', 'uuid', 'pid', ] @deprecated('There is no on/off function for Air Fryers.') async def toggle_switch(self, toggle: bool | None = None) -> bool: """Turn on or off the air fryer.""" return toggle if toggle is not None else not self.is_on def _build_request( self, json_cmd: dict | None = None, method: str | None = None, ) -> dict: """Return body of api calls.""" req_dict = Helpers.get_class_attributes(DefaultValues, self.request_keys) req_dict.update(Helpers.get_class_attributes(self.manager, self.request_keys)) req_dict.update(Helpers.get_class_attributes(self, self.request_keys)) req_dict['method'] = method or 'bypass' req_dict['jsonCmd'] = json_cmd or {} return req_dict def _build_status_body(self, cmd_dict: dict) -> dict: """Return body of api calls.""" body = self._build_request() body.update( { 'uuid': self.uuid, 'configModule': self.config_module, 'jsonCmd': cmd_dict, 'pid': self.pid, 'accountID': self.manager.account_id, } ) return body @property def temp_unit(self) -> str | None: """Return temp unit.""" return self.state.temp_unit async def get_details(self) -> None: """Get Air Fryer Status and Details.""" cmd = {'getStatus': 'status'} req_body = self._build_request(json_cmd=cmd) url = '/cloud/v1/deviceManaged/bypass' r_dict, _ = await self.manager.async_call_api(url, 'post', json_object=req_body) resp = Helpers.process_dev_response(logger, 'get_details', self, r_dict) if resp is None: self.state.device_status = DeviceStatus.OFF self.state.connection_status = ConnectionStatus.OFFLINE return return_status = resp.get('result', {}).get('returnStatus') if return_status is None: LibraryLogger.log_device_api_response_error( logger, self.device_name, self.device_type, 'get_details', msg='Return status not found in response', ) return self.state.status_response(return_status) async def check_status(self) -> None: """Update status if REFRESH_INTERVAL has passed.""" seconds_elapsed = int(time.time()) - self.last_update logger.debug('Seconds elapsed between updates: %s', seconds_elapsed) refresh = False if self.refresh_interval is None: refresh = bool(seconds_elapsed > REFRESH_INTERVAL) elif self.refresh_interval == 0: refresh = True elif self.refresh_interval > 0: refresh = bool(seconds_elapsed > self.refresh_interval) if refresh is True: logger.debug('Updating status, %s seconds elapsed', seconds_elapsed) await self.update() async def end(self) -> bool: """End the cooking process.""" await self.check_status() if self.state.preheat is False and self.state.cook_status in [ 'cookStop', 'cooking', ]: cmd = {'cookMode': {'cookStatus': 'end'}} elif self.state.preheat is True and self.state.cook_status in [ 'preheatStop', 'heating', ]: cmd = {'preheat': {'cookStatus': 'end'}} else: logger.debug( 'Cannot end %s as it is not cooking or preheating', self.device_name ) return False status_api = await self._status_api(cmd) if status_api is False: return False self.state.set_standby() return True async def pause(self) -> bool: """Pause the cooking process.""" await self.check_status() if self.state.cook_status not in ['cooking', 'heating']: logger.debug( 'Cannot pause %s as it is not cooking or preheating', self.device_name ) return False if self.state.preheat is True: cmd = {'preheat': {'preheatStatus': 'stop'}} else: cmd = {'cookMode': {'cookStatus': 'stop'}} status_api = await self._status_api(cmd) if status_api is True: if self.state.preheat is True: self.state.cook_status = 'preheatStop' else: self.state.cook_status = 'cookStop' return True return False def _validate_temp(self, set_temp: int) -> bool: """Temperature validation.""" if self.state.temp_unit == 'fahrenheit' and ( set_temp < self.state.min_temp_f or set_temp > self.state.max_temp_f ): logger.debug('Invalid temperature %s for %s', set_temp, self.device_name) return False if self.state.temp_unit == 'celsius' and ( set_temp < self.state.min_temp_c or set_temp > self.state.max_temp_c ): logger.debug('Invalid temperature %s for %s', set_temp, self.device_name) return False return True async def cook(self, set_temp: int, set_time: int) -> bool: """Set cook time and temperature in Minutes.""" await self.check_status() if self._validate_temp(set_temp) is False: return False return await self._set_cook(set_temp, set_time) async def resume(self) -> bool: """Resume paused preheat or cook.""" await self.check_status() if self.state.cook_status not in ['preheatStop', 'cookStop']: logger.debug('Cannot resume %s as it is not paused', self.device_name) return False if self.state.preheat is True: cmd = {'preheat': {'preheatStatus': 'heating'}} else: cmd = {'cookMode': {'cookStatus': 'cooking'}} status_api = await self._status_api(cmd) if status_api is True: if self.state.preheat is True: self.state.cook_status = 'heating' else: self.state.cook_status = 'cooking' return True return False async def set_preheat(self, target_temp: int, cook_time: int) -> bool: """Set preheat mode with cooking time.""" await self.check_status() if self.state.cook_status not in ['standby', 'cookEnd', 'preheatEnd']: logger.debug( 'Cannot set preheat for %s as it is not in standby', self.device_name ) return False if self._validate_temp(target_temp) is False: return False cmd = self._cmd_api_dict cmd['preheatSetTime'] = 5 cmd['preheatStatus'] = 'heating' cmd['targetTemp'] = target_temp cmd['cookSetTime'] = cook_time json_cmd = {'preheat': cmd} return await self._status_api(json_cmd) async def cook_from_preheat(self) -> bool: """Start Cook when preheat has ended.""" await self.check_status() if self.state.preheat is False or self.state.cook_status != 'preheatEnd': logger.debug('Cannot start cook from preheat for %s', self.device_name) return False return await self._set_cook(status='cooking') async def update(self) -> None: """Update the device details.""" await self.get_details() @property def _cmd_api_base(self) -> dict: """Return Base api dictionary for setting status.""" return { 'mode': COOK_MODE, 'accountId': self.manager.account_id, } @property def _cmd_api_dict(self) -> dict: """Return API dictionary for setting status.""" cmd = self._cmd_api_base cmd.update( { 'appointmentTs': 0, 'recipeId': RECIPE_ID, 'readyStart': self.ready_start, 'recipeType': RECIPE_TYPE, 'customRecipe': CUSTOM_RECIPE, } ) return cmd async def _set_cook( self, set_temp: int | None = None, set_time: int | None = None, status: str = 'cooking', ) -> bool: if set_temp is not None and set_time is not None: set_cmd = self._cmd_api_dict set_cmd['cookSetTime'] = set_time set_cmd['cookSetTemp'] = set_temp else: set_cmd = self._cmd_api_base set_cmd['cookStatus'] = status cmd = {'cookMode': set_cmd} return await self._status_api(cmd) async def _status_api(self, json_cmd: dict) -> bool: """Set API status with jsonCmd.""" body = self._build_status_body(json_cmd) url = '/cloud/v1/deviceManaged/bypass' r_dict, _ = await self.manager.async_call_api(url, 'post', json_object=body) resp = Helpers.process_dev_response(logger, 'set_status', self, r_dict) if resp is None: return False self.last_update = int(time.time()) self.state.status_request(json_cmd) await self.update() return True webdjoe-pyvesync-eb8cecb/src/pyvesync/devices/vesyncoutlet.py000066400000000000000000001063501507433633000250640ustar00rootroot00000000000000"""Etekcity Outlets.""" from __future__ import annotations import logging from typing import TYPE_CHECKING from typing_extensions import deprecated from pyvesync.base_devices.outlet_base import VeSyncOutlet from pyvesync.const import STATUS_OK, ConnectionStatus, DeviceStatus from pyvesync.models.base_models import DefaultValues, RequestHeaders from pyvesync.models.bypass_models import TimerModels from pyvesync.models.outlet_models import ( Request15ADetails, Request15ANightlight, Request15AStatus, RequestOutdoorStatus, Response7AOutlet, Response10ADetails, Response15ADetails, ResponseBSDGO1OutletResult, ResponseOutdoorDetails, ResultESW10Details, Timer7AItem, ) from pyvesync.utils.device_mixins import ( BypassV1Mixin, BypassV2Mixin, process_bypassv1_result, process_bypassv2_result, ) from pyvesync.utils.helpers import Helpers, Timer from pyvesync.utils.logs import LibraryLogger if TYPE_CHECKING: from pyvesync import VeSync from pyvesync.device_map import OutletMap from pyvesync.models.vesync_models import ResponseDeviceDetailsModel logger = logging.getLogger(__name__) class VeSyncOutlet7A(VeSyncOutlet): """Etekcity 7A Round Outlet Class. Args: details (ResponseDeviceDetailsModel): The device details. manager (VeSync): The VeSync manager. feature_map (OutletMap): The feature map for the device. Attributes: state (OutletState): The state of the outlet. last_response (ResponseInfo): Last response from API call. device_status (str): Device status. connection_status (str): Connection status. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: OutletMap ) -> None: """Initialize Etekcity 7A round outlet class.""" super().__init__(details, manager, feature_map) def _build_headers(self) -> dict: """Build 7A Outlet Request Headers.""" headers = RequestHeaders.copy() headers.update( { 'tz': self.manager.time_zone, 'tk': self.manager.token, 'accountid': self.manager.account_id, } ) return headers async def get_details(self) -> None: r_dict, _ = await self.manager.async_call_api( '/v1/device/' + self.cid + '/detail', 'get', headers=self._build_headers(), ) if not isinstance(r_dict, dict): LibraryLogger.log_device_api_response_error( logger, self.device_name, self.device_type, 'get_details', 'Response is not valid JSON', ) return if 'error' in r_dict: _ = Helpers.process_dev_response(logger, 'get_details', self, r_dict) return self.state.update_ts() resp_model = Response7AOutlet.from_dict(r_dict) self.state.connection_status = ConnectionStatus.ONLINE self.state.device_status = resp_model.deviceStatus self.state.active_time = resp_model.activeTime self.state.energy = resp_model.energy self.state.power = self.parse_energy_detail(resp_model.power) self.state.voltage = self.parse_energy_detail(resp_model.voltage) @staticmethod def parse_energy_detail(energy: str | float) -> float: """Parse energy details to be compatible with new and old firmware.""" try: if isinstance(energy, str) and ':' in energy: power = round(float(Helpers.calculate_hex(energy)), 2) else: power = float(energy) except ValueError: logger.debug('Error parsing power response - %s', energy) power = 0 return power async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.device_status != DeviceStatus.ON toggle_str = DeviceStatus.ON if toggle else DeviceStatus.OFF r_dict, status_code = await self.manager.async_call_api( f'/v1/wifi-switch-1.3/{self.cid}/status/{toggle_str}', 'put', headers=Helpers.req_legacy_headers(self.manager), ) if status_code != STATUS_OK: LibraryLogger.log_device_api_response_error( logger, self.device_name, self.device_type, 'toggle_switch', 'Response code is not 200', ) return False if isinstance(r_dict, dict) and 'error' in r_dict: _ = Helpers.process_dev_response(logger, 'get_details', self, r_dict) return False self.state.update_ts() self.state.device_status = toggle_str self.state.connection_status = ConnectionStatus.ONLINE return True async def get_timer(self) -> None: r_dict, status_code = await self.manager.async_call_api( f'/v2/device/{self.cid}/timer', 'get', headers=Helpers.req_legacy_headers(self.manager), ) if not r_dict or status_code != STATUS_OK: logger.debug('No timer set.') self.state.timer = None return if isinstance(r_dict, list) and len(r_dict) > 0: timer_model = Helpers.model_maker(logger, Timer7AItem, 'get_timer', r_dict[0]) if timer_model is None: self.state.timer = None return self.state.timer = Timer( timer_duration=int(timer_model.counterTimer), id=int(timer_model.timerID), action=timer_model.action, ) if timer_model.timerStatus == 'off': self.state.timer.pause() return self.state.timer = None async def set_timer(self, duration: int, action: str | None = None) -> bool: if action is None: action = ( DeviceStatus.ON if self.state.device_status == DeviceStatus.OFF else DeviceStatus.OFF ) if not isinstance(action, str) or action not in [ DeviceStatus.ON, DeviceStatus.OFF, ]: logger.error('Invalid action for timer - %s', action) return False update_dict = { 'action': action, 'counterTimer': duration, 'timerStatus': 'start', 'conflictAwayIds': [], 'conflictScheduleIds': [], 'conflictTimerIds': [], } r_dict, status_code = await self.manager.async_call_api( f'/v2/device/{self.cid}/timer', 'post', headers=Helpers.req_legacy_headers(self.manager), json_object=update_dict, ) if status_code != STATUS_OK or not isinstance(r_dict, dict): logger.debug('Failed to set timer.') return False if 'error' in r_dict: logger.debug('Error in response: %s', r_dict['error']) return False result_model = Helpers.model_maker( logger, TimerModels.ResultV1SetTimer, 'set_timer', r_dict, self ) if result_model is None: logger.debug('Failed to set timer.') return False if result_model.timerID == '': logger.debug('Unable to set timer.') if result_model.conflictTimerIds: logger.debug('Conflicting timer IDs - %s', result_model.conflictTimerIds) return False self.state.timer = Timer(duration, action, int(result_model.timerID)) return True async def clear_timer(self) -> bool: if self.state.timer is None: logger.debug('No timer set, nothing to clear, run get_timer().') return False _, status_code = await self.manager.async_call_api( f'/v2/device/{self.cid}/timer/{self.state.timer.id}', 'delete', headers=Helpers.req_legacy_headers(self.manager), ) if status_code != STATUS_OK: return False self.state.timer = None return True class VeSyncOutlet10A(VeSyncOutlet): """Etekcity 10A Round Outlets. Args: details (ResponseDeviceDetailsModel): The device details. manager (VeSync): The VeSync manager. feature_map (OutletMap): The feature map for the device. Attributes: state (OutletState): The state of the outlet. last_response (ResponseInfo): Last response from API call. device_status (str): Device status. connection_status (str): Connection status. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: OutletMap ) -> None: """Initialize 10A outlet class.""" super().__init__(details, manager, feature_map) self.request_keys = [ 'acceptLanguage', 'appVersion', 'accountId', 'mobileId', 'phoneBrand', 'phoneOS', 'timeZone', 'token', 'traceId', 'uuid', ] def _build_headers(self) -> dict: """Build auth headers for 10A Outlet.""" headers = RequestHeaders.copy() headers.update( { 'tz': self.manager.time_zone, 'tk': self.manager.token, 'accountid': self.manager.account_id, } ) return headers def _build_detail_request(self, method: str) -> dict: """Build 10A Outlet Request.""" body = Helpers.get_class_attributes(DefaultValues, self.request_keys) body.update(Helpers.get_class_attributes(self.manager, self.request_keys)) body.update(Helpers.get_class_attributes(self, self.request_keys)) body['method'] = method return body def _build_status_request(self, status: str) -> dict: """Build 10A Outlet Request to set status.""" status_keys = ['accountID', 'token', 'timeZone', 'uuid'] body = Helpers.get_class_attributes(self.manager, status_keys) body.update(Helpers.get_class_attributes(self, status_keys)) body['status'] = status return body async def get_details(self) -> None: body = self._build_detail_request('devicedetail') r_dict, _ = await self.manager.async_call_api( '/10a/v1/device/devicedetail', 'post', headers=Helpers.req_legacy_headers(self.manager), json_object=body, ) r = Helpers.process_dev_response(logger, 'get_details', self, r_dict) if r is None: return resp_model = Response10ADetails.from_dict(r) self.state.device_status = resp_model.deviceStatus or 'off' self.state.connection_status = resp_model.connectionStatus or 'offline' self.state.energy = resp_model.energy or 0 self.state.power = resp_model.power or 0 self.state.voltage = resp_model.voltage or 0 async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.device_status != DeviceStatus.ON toggle_str = DeviceStatus.ON if toggle else DeviceStatus.OFF body = self._build_status_request(toggle_str) headers = self._build_headers() r_dict, _ = await self.manager.async_call_api( '/10a/v1/device/devicestatus', 'put', headers=headers, json_object=body, ) response = Helpers.process_dev_response(logger, 'toggle_switch', self, r_dict) if response is None: return False self.state.device_status = toggle_str self.state.connection_status = ConnectionStatus.ONLINE return True class VeSyncOutlet15A(BypassV1Mixin, VeSyncOutlet): """Class for Etekcity 15A Rectangular Outlets. Args: details (ResponseDeviceDetailsModel): The device details. manager (VeSync): The VeSync manager. feature_map (OutletMap): The feature map for the device. Attributes: state (OutletState): The state of the outlet. last_response (ResponseInfo): Last response from API call. device_status (str): Device status. connection_status (str): Connection status. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: OutletMap ) -> None: """Initialize 15A rectangular outlets.""" super().__init__(details, manager, feature_map) async def get_details(self) -> None: r_dict = await self.call_bypassv1_api( Request15ADetails, method='deviceDetail', endpoint='deviceDetail' ) r = Helpers.process_dev_response(logger, 'get_details', self, r_dict) if r is None: return resp_model = Response15ADetails.from_dict(r) result = resp_model.result self.state.device_status = result.deviceStatus self.state.connection_status = result.connectionStatus self.state.nightlight_status = result.nightLightStatus self.state.nightlight_brightness = result.nightLightBrightness self.state.nightlight_automode = result.nightLightAutoMode self.state.active_time = result.activeTime self.state.power = result.power or 0 self.state.voltage = result.voltage or 0 self.state.energy = result.energy or 0 async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.device_status != DeviceStatus.ON toggle_str = DeviceStatus.ON if toggle else DeviceStatus.OFF r_dict = await self.call_bypassv1_api( Request15AStatus, update_dict={'status': toggle_str}, method='deviceStatus', endpoint='deviceStatus', ) response = Helpers.process_dev_response(logger, 'toggle_switch', self, r_dict) if response is None: return False self.state.device_status = toggle_str self.state.connection_status = ConnectionStatus.ONLINE return True async def set_nightlight_state(self, mode: str) -> bool: """Set nightlight state for 15A Outlets.""" if mode.lower() not in self.nightlight_modes: logger.error('Invalid nightlight mode - %s', mode) return False mode = mode.lower() r_dict = await self.call_bypassv1_api( Request15ANightlight, update_dict={'mode': mode}, method='outletNightLightCtl', endpoint='outletNightLightCtl', ) response = Helpers.process_dev_response( logger, 'set_nightlight_state', self, r_dict ) if response is None: return False self.state.nightlight_status = mode self.state.connection_status = ConnectionStatus.ONLINE return True async def get_timer(self) -> None: method = 'getTimers' endpoint = f'/timer/{method}' r_dict = await self.call_bypassv1_api( Request15ADetails, method=method, endpoint=endpoint ) result_model = process_bypassv1_result( self, logger, 'get_timer', r_dict, TimerModels.ResultV1GetTimer ) if result_model is None: return timers = result_model.timers if not isinstance(timers, list) or len(timers) == 0: self.state.timer = None return timer = timers[0] if not isinstance(timer, TimerModels.TimerItemV1): logger.debug('Invalid timer model - %s', timer) return self.state.timer = Timer( timer_duration=int(timer.counterTimer), id=int(timer.timerID), action=timer.action, ) async def set_timer(self, duration: int, action: str | None = None) -> bool: if action is None: action = ( DeviceStatus.ON if self.state.device_status == DeviceStatus.OFF else DeviceStatus.OFF ) if not isinstance(action, str) or action not in [ DeviceStatus.ON, DeviceStatus.OFF, ]: logger.error('Invalid action for timer - %s', action) return False update_dict = { 'action': action, 'counterTime': str(duration), } r_dict = await self.call_bypassv1_api( TimerModels.RequestV1SetTime, update_dict=update_dict, method='addTimer', endpoint='timer/addTimer', ) result_model = process_bypassv1_result( self, logger, 'set_timer', r_dict, TimerModels.ResultV1SetTimer ) if result_model is None: return False self.state.timer = Timer(duration, action, int(result_model.timerID)) return True async def clear_timer(self) -> bool: if self.state.timer is None: logger.debug('No timer set, nothing to clear, run get_timer().') return False if self.state.timer.time_remaining == 0: logger.debug('Timer already ended.') self.state.timer = None return True r_dict = await self.call_bypassv1_api( TimerModels.RequestV1ClearTimer, {'timerId': str(self.state.timer.id)}, method='deleteTimer', endpoint='timer/deleteTimer', ) r_dict = Helpers.process_dev_response(logger, 'clear_timer', self, r_dict) if r_dict is None: if ( self.last_response is not None and self.last_response.name == 'TIMER_NOT_EXISTS' ): self.state.timer = None return False self.state.timer = None return True class VeSyncOutdoorPlug(BypassV1Mixin, VeSyncOutlet): """Class to hold Etekcity outdoor outlets. Args: details (ResponseDeviceDetailsModel): The device details. manager (VeSync): The VeSync manager. feature_map (OutletMap): The feature map for the device. Attributes: state (OutletState): The state of the outlet. last_response (ResponseInfo): Last response from API call. device_status (str): Device status. connection_status (str): Connection status. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: OutletMap ) -> None: """Initialize Etekcity Outdoor Plug class.""" super().__init__(details, manager, feature_map) async def get_details(self) -> None: r_dict = await self.call_bypassv1_api( Request15ADetails, method='deviceDetail', endpoint='deviceDetail' ) r = Helpers.process_dev_response(logger, 'get_details', self, r_dict) if r is None: return resp_model = ResponseOutdoorDetails.from_dict(r) self.state.connection_status = resp_model.result.connectionStatus self.state.energy = resp_model.result.energy self.state.power = resp_model.result.power self.state.voltage = resp_model.result.voltage self.state.active_time = resp_model.result.activeTime for outlet in resp_model.result.subDevices: if not isinstance(self.sub_device_no, float): continue if int(self.sub_device_no) == int(outlet.subDeviceNo): self.state.device_status = outlet.subDeviceStatus @deprecated('Use toggle_switch(toggle: bool | None) instead') async def toggle(self, status: str) -> bool: """Deprecated - use toggle_switch().""" toggle = status != DeviceStatus.ON return await self.toggle_switch(toggle) async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.device_status != DeviceStatus.ON status = DeviceStatus.ON if toggle else DeviceStatus.OFF r_dict = await self.call_bypassv1_api( RequestOutdoorStatus, update_dict={'switchNo': self.sub_device_no, 'status': status}, method='deviceStatus', endpoint='deviceStatus', ) response = Helpers.process_dev_response(logger, 'toggle', self, r_dict) if response is None: return False self.state.device_status = status self.state.connection_status = ConnectionStatus.ONLINE return True async def get_timer(self) -> None: method = 'getTimers' endpoint = f'/timer/{method}' r_dict = await self.call_bypassv1_api( TimerModels.RequestV1GetTimer, {'switchNo': self.sub_device_no}, method=method, endpoint=endpoint, ) result_model = process_bypassv1_result( self, logger, 'get_timer', r_dict, TimerModels.ResultV1GetTimer ) if result_model is None: return timers = result_model.timers if not isinstance(timers, list) or len(timers) == 0: self.state.timer = None return if len(timers) > 1: logger.debug( ( ( 'Multiple timers found - %s, this method ' 'will only return the most recent timer created.' ), ), timers, ) timer = timers[0] if not isinstance(timer, TimerModels.TimerItemV1): logger.debug('Invalid timer model - %s', timer) return self.state.timer = Timer( timer_duration=int(timer.counterTimer), id=int(timer.timerID), action=timer.action, ) async def set_timer(self, duration: int, action: str | None = None) -> bool: if action is None: action = ( DeviceStatus.ON if self.state.device_status == DeviceStatus.OFF else DeviceStatus.OFF ) if not isinstance(action, str) or action not in [ DeviceStatus.ON, DeviceStatus.OFF, ]: logger.error('Invalid action for timer - %s', action) return False update_dict = { 'action': action, 'counterTime': str(duration), 'switchNo': self.sub_device_no, } r_dict = await self.call_bypassv1_api( TimerModels.RequestV1SetTime, update_dict=update_dict, method='addTimer', endpoint='timer/addTimer', ) result_model = process_bypassv1_result( self, logger, 'set_timer', r_dict, TimerModels.ResultV1SetTimer ) if result_model is None: return False self.state.timer = Timer(duration, action, int(result_model.timerID)) return True async def clear_timer(self) -> bool: if self.state.timer is None: logger.debug('No timer set, nothing to clear, run get_timer().') return False if self.state.timer.time_remaining == 0: logger.debug('Timer already ended.') self.state.timer = None return True r_dict = await self.call_bypassv1_api( TimerModels.RequestV1ClearTimer, {'timerId': str(self.state.timer.id)}, method='deleteTimer', endpoint='timer/deleteTimer', ) r_dict = Helpers.process_dev_response(logger, 'clear_timer', self, r_dict) if r_dict is None: return False self.state.timer = None return True class VeSyncOutletBSDGO1(BypassV2Mixin, VeSyncOutlet): """VeSync BSDGO1 smart plug. Note that this device does not support energy monitoring. Args: details (ResponseDeviceDetailsModel): The device details. manager (VeSync): The VeSync manager. feature_map (OutletMap): The feature map for the device. Attributes: state (OutletState): The state of the outlet. last_response (ResponseInfo): Last response from API call. device_status (str): Device status. connection_status (str): Connection status. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: OutletMap ) -> None: """Initialize BSDGO1 smart plug class.""" super().__init__(details, manager, feature_map) async def get_details(self) -> None: r_dict = await self.call_bypassv2_api('getProperty') resp_model = process_bypassv2_result( self, logger, 'get_details', r_dict, ResponseBSDGO1OutletResult ) if resp_model is None: return device_state = resp_model.powerSwitch_1 str_status = DeviceStatus.ON if device_state == 1 else DeviceStatus.OFF self.state.device_status = str_status self.state.connection_status = resp_model.connectionStatus self.state.active_time = resp_model.active_time async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.device_status != DeviceStatus.ON toggle_int = 1 if toggle else 0 r_dict = await self.call_bypassv2_api( 'setProperty', data={'powerSwitch_1': toggle_int} ) r = Helpers.process_dev_response(logger, 'toggle_switch', self, r_dict) if r is None: return False self.state.device_status = DeviceStatus.ON if toggle else DeviceStatus.OFF self.state.connection_status = ConnectionStatus.ONLINE return True async def _set_power(self, power: bool) -> bool: """Set power state of BSDGO1 outlet.""" return await self.toggle_switch(power) class VeSyncESW10USA(BypassV2Mixin, VeSyncOutlet): """VeSync ESW10 USA outlet. Note that this device does not support energy monitoring. Args: details (ResponseDeviceDetailsModel): The device details. manager (VeSync): The VeSync manager. feature_map (OutletMap): The feature map for the device. Attributes: state (OutletState): The state of the outlet. last_response (ResponseInfo): Last response from API call. device_status (str): Device status. connection_status (str): Connection status. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: OutletMap ) -> None: """Initialize ESW10 USA outlet.""" super().__init__(details, manager, feature_map) async def get_details(self) -> None: payload_data = { 'id': 0, } payload_method = 'getSwitch' r_dict = await self.call_bypassv2_api(payload_method, payload_data) result = process_bypassv2_result( self, logger, 'get_details', r_dict, ResultESW10Details ) if not isinstance(result, dict) or not isinstance(result.get('enabled'), bool): logger.debug('Error getting %s details', self.device_name) self.state.connection_status = ConnectionStatus.OFFLINE return self.state.device_status = DeviceStatus.from_bool(result.enabled) self.state.connection_status = ConnectionStatus.ONLINE async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.device_status != DeviceStatus.ON payload_data = { 'id': 0, 'enabled': toggle, } payload_method = 'setSwitch' r_dict = await self.call_bypassv2_api(payload_method, payload_data) result = Helpers.process_dev_response(logger, 'toggle_switch', self, r_dict) if not isinstance(result, dict): logger.debug('Error toggling %s switch', self.device_name) return False self.state.device_status = DeviceStatus.from_bool(toggle) self.state.connection_status = ConnectionStatus.ONLINE return True async def get_timer(self) -> Timer | None: r_dict = await self.call_bypassv2_api('getTimer') result_model = process_bypassv2_result( self, logger, 'get_timer', r_dict, TimerModels.ResultV2GetTimer ) if result_model is None: return None timers = result_model.timers if not timers: self.state.timer = None return None if len(timers) > 1: logger.debug( ( 'Multiple timers found - %s, this method ' 'will only return the most recent timer created.' ), timers, ) timer = timers[0] self.state.timer = Timer( timer_duration=int(timer.remain), id=int(timer.id), action=timer.action, ) return self.state.timer async def set_timer(self, duration: int, action: str | None = None) -> bool: if action is None: action = ( DeviceStatus.ON if self.state.device_status == DeviceStatus.OFF else DeviceStatus.OFF ) if action not in [DeviceStatus.ON, DeviceStatus.OFF]: logger.error('Invalid action for timer - %s', action) return False payload_data = {'action': action, 'total': duration} r_dict = await self.call_bypassv2_api( payload_method='addTimer', data=payload_data, ) result_model = process_bypassv2_result( self, logger, 'set_timer', r_dict, TimerModels.ResultV2SetTimer ) if result_model is None: return False if result_model.id is None: logger.debug('Unable to set timer.') return False self.state.timer = Timer(duration, action, int(result_model.id)) return True async def clear_timer(self) -> bool: if self.state.timer is None: logger.debug('No timer set, nothing to clear, run get_timer().') return False if self.state.timer.time_remaining == 0: logger.debug('Timer already ended.') self.state.timer = None return True r_dict = await self.call_bypassv2_api( payload_method='delTimer', data={'id': self.state.timer.id} ) r_dict = Helpers.process_dev_response(logger, 'clear_timer', self, r_dict) if r_dict is None: return False self.state.timer = None return True webdjoe-pyvesync-eb8cecb/src/pyvesync/devices/vesyncpurifier.py000066400000000000000000001147711507433633000254030ustar00rootroot00000000000000"""VeSync API for controlling air purifiers.""" from __future__ import annotations import logging from typing import TYPE_CHECKING from typing_extensions import deprecated from pyvesync.base_devices.purifier_base import VeSyncPurifier from pyvesync.const import ( ConnectionStatus, DeviceStatus, NightlightModes, PurifierAutoPreference, PurifierModes, ) from pyvesync.models.bypass_models import ( ResultV2GetTimer, ResultV2SetTimer, ) from pyvesync.models.purifier_models import ( InnerPurifierBaseResult, Purifier131Result, PurifierCoreDetailsResult, PurifierSproutResult, PurifierV2EventTiming, PurifierV2TimerActionItems, PurifierV2TimerPayloadData, PurifierVitalDetailsResult, RequestPurifier131, RequestPurifier131Level, RequestPurifier131Mode, ) from pyvesync.utils.device_mixins import ( BypassV1Mixin, BypassV2Mixin, process_bypassv2_result, ) from pyvesync.utils.helpers import Helpers, Timer if TYPE_CHECKING: from pyvesync import VeSync from pyvesync.device_map import PurifierMap from pyvesync.models.vesync_models import ResponseDeviceDetailsModel _LOGGER = logging.getLogger(__name__) class VeSyncAirBypass(BypassV2Mixin, VeSyncPurifier): """Initialize air purifier devices. Instantiated by VeSync manager object. Inherits from VeSyncBaseDevice class. This is the primary class for most air purifiers, using the Bypass V2 API, except the original LV-PUR131S. Parameters: details (dict): Dictionary of device details manager (VeSync): Instantiated VeSync object used to make API calls feature_map (PurifierMap): Device map template Attributes: state (PurifierState): State of the device. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. modes (list[str]): List of modes supported by the device. fan_levels (list[int]): List of fan levels supported by the device. nightlight_modes (list[str]): List of nightlight modes supported by the device. auto_preferences (list[str]): List of auto preferences supported by the device. Notes: The `details` attribute holds device information that is updated when the `update()` method is called. An example of the `details` attribute: ```python json.dumps(self.details, indent=4) { 'filter_life': 0, 'mode': 'manual', 'level': 0, 'display': False, 'child_lock': False, 'night_light': 'off', 'air_quality': 0 # air quality level 'air_quality_value': 0, # PM2.5 value from device, 'display_forever': False } ``` """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: PurifierMap, ) -> None: """Initialize VeSync Air Purifier Bypass Base Class.""" super().__init__(details, manager, feature_map) def _set_purifier_state(self, result: PurifierCoreDetailsResult) -> None: """Populate PurifierState with details from API response. Populates `self.state` and instance variables with device details. Args: result (InnerPurifierResult): Data model for inner result in purifier details response. """ self.state.connection_status = ConnectionStatus.ONLINE self.state.device_status = DeviceStatus.ON if result.enabled else DeviceStatus.OFF self.state.filter_life = result.filter_life or 0 self.state.mode = result.mode self.state.fan_level = result.level or 0 self.state.fan_set_level = result.levelNew or 0 self.state.display_status = ( DeviceStatus.ON if result.display else DeviceStatus.OFF ) self.state.child_lock = result.child_lock or False config = result.configuration if config is not None: self.state.display_set_status = ( DeviceStatus.ON if config.display else DeviceStatus.OFF ) self.state.display_forever = config.display_forever if config.auto_preference is not None: self.state.auto_preference_type = config.auto_preference.type self.state.auto_room_size = config.auto_preference.room_size if self.supports_air_quality is True: self.state.pm25 = result.air_quality_value self.state.set_air_quality_level(result.air_quality) if result.night_light is not None: self.state.nightlight_status = DeviceStatus(result.night_light) async def get_details(self) -> None: r_dict = await self.call_bypassv2_api('getPurifierStatus') resp_model = process_bypassv2_result( self, _LOGGER, 'get_details', r_dict, PurifierCoreDetailsResult ) if resp_model is None: return self._set_purifier_state(resp_model) async def get_timer(self) -> Timer | None: r_bytes = await self.call_bypassv2_api('getTimer') resp_model = process_bypassv2_result( self, _LOGGER, 'get_timer', r_bytes, ResultV2GetTimer ) if resp_model is None: return None timers = resp_model.timers if not timers: _LOGGER.debug('No timers found') self.state.timer = None return None timer = timers[0] self.state.timer = Timer( timer_duration=timer.total, action=timer.action, id=timer.id, remaining=timer.remain, ) self.state.device_status = DeviceStatus.ON self.state.connection_status = ConnectionStatus.ONLINE _LOGGER.debug('Timer found: %s', str(self.state.timer)) return self.state.timer async def set_timer(self, duration: int, action: str | None = None) -> bool: action = DeviceStatus.OFF # No other actions available for this device if self.state.device_status != DeviceStatus.ON: _LOGGER.debug("Can't set timer when device is off") payload_data = {'action': str(action), 'total': duration} r_dict = await self.call_bypassv2_api('addTimer', payload_data) resp_model = process_bypassv2_result( self, _LOGGER, 'set_timer', r_dict, ResultV2SetTimer ) if resp_model is None: return False self.state.timer = Timer(timer_duration=duration, action='off', id=resp_model.id) self.state.device_status = DeviceStatus.ON self.state.connection_status = ConnectionStatus.ONLINE return True async def clear_timer(self) -> bool: if self.state.timer is None: _LOGGER.debug('No timer to clear, run `get_timer()` to get active timer') return False payload_data = {'id': self.state.timer.id} r_dict = await self.call_bypassv2_api('delTimer', payload_data) r = Helpers.process_dev_response(_LOGGER, 'clear_timer', self, r_dict) if r is None: return False _LOGGER.debug('Timer cleared') self.state.timer = None self.state.device_status = DeviceStatus.ON self.state.connection_status = ConnectionStatus.ONLINE return True async def set_auto_preference(self, preference: str, room_size: int = 800) -> bool: if self.state.device_status != DeviceStatus.ON: _LOGGER.debug("Can't set auto preference when device is off") payload_data = {'type': preference, 'room_size': room_size} r_dict = await self.call_bypassv2_api('setAutoPreference', payload_data) r = Helpers.process_dev_response(_LOGGER, 'set_auto_preference', self, r_dict) if r is None: return False self.state.connection_status = ConnectionStatus.ONLINE self.state.auto_preference_type = preference self.state.auto_room_size = room_size return True async def set_fan_speed(self, speed: None | int = None) -> bool: speeds: list = self.fan_levels current_speed = self.state.fan_level 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: new_speed = Helpers.bump_level(current_speed, self.fan_levels) data = { 'id': 0, 'level': new_speed, 'type': 'wind', } r_dict = await self.call_bypassv2_api('setLevel', data) r = Helpers.process_dev_response(_LOGGER, 'set_fan_speed', self, r_dict) if r is None: return False self.state.fan_level = new_speed self.state.fan_set_level = new_speed self.state.mode = PurifierModes.MANUAL # Set mode to manual to set fan speed self.state.device_status = DeviceStatus.ON self.state.connection_status = ConnectionStatus.ONLINE return True async def toggle_child_lock(self, toggle: bool | None = None) -> bool: """Toggle child lock. Set child lock to on or off. Internal method used by `turn_on_child_lock` and `turn_off_child_lock`. Args: toggle (bool): True to turn child lock on, False to turn off Returns: bool : True if child lock is set, False if not """ if toggle is None: toggle = self.state.child_lock is False data = {'child_lock': toggle} r_dict = await self.call_bypassv2_api('setChildLock', data) r = Helpers.process_dev_response(_LOGGER, 'toggle_child_lock', self, r_dict) if r is None: return False self.state.child_lock = toggle self.state.connection_status = ConnectionStatus.ONLINE return True async def reset_filter(self) -> bool: """Reset filter to 100%. Returns: bool : True if filter is reset, False if not """ r_dict = await self.call_bypassv2_api('resetFilter') r = Helpers.process_dev_response(_LOGGER, 'reset_filter', self, r_dict) return bool(r) @deprecated('Use set_mode(mode: str) instead.') async def mode_toggle(self, mode: str) -> bool: """Deprecated - Set purifier mode.""" return await self.set_mode(mode) async def set_mode(self, mode: str) -> bool: if mode.lower() not in self.modes: _LOGGER.debug('Invalid purifier mode used - %s', mode) return False if mode.lower() == PurifierModes.MANUAL: return await self.set_fan_speed(self.state.fan_level or 1) data = { 'mode': mode, } r_dict = await self.call_bypassv2_api('setPurifierMode', data) r = Helpers.process_dev_response(_LOGGER, 'set_mode', self, r_dict) if r is None: return False self.state.mode = mode self.state.device_status = DeviceStatus.ON self.state.connection_status = ConnectionStatus.ONLINE return True async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.device_status != DeviceStatus.ON if not isinstance(toggle, bool): _LOGGER.debug('Invalid toggle value for purifier switch') return False data = {'enabled': toggle, 'id': 0} r_dict = await self.call_bypassv2_api('setSwitch', data) r = Helpers.process_dev_response(_LOGGER, 'toggle_switch', self, r_dict) if r is None: return False self.state.device_status = DeviceStatus.ON if toggle else DeviceStatus.OFF self.state.connection_status = ConnectionStatus.ONLINE return True async def toggle_display(self, mode: bool) -> bool: if not isinstance(mode, bool): _LOGGER.debug('Mode must be True or False') return False data = {'state': mode} r_dict = await self.call_bypassv2_api('setDisplay', data) r = Helpers.process_dev_response(_LOGGER, 'set_display', self, r_dict) if r is None: return False self.state.connection_status = ConnectionStatus.ONLINE self.state.display_set_status = DeviceStatus.from_bool(mode) return True async def set_nightlight_mode(self, mode: str) -> bool: if not self.supports_nightlight: _LOGGER.debug('Device does not support night light') return False if mode.lower() not in self.nightlight_modes: _LOGGER.warning('Invalid nightlight mode used (on, off or dim)- %s', mode) return False r_dict = await self.call_bypassv2_api( 'setNightLight', {'night_light': mode.lower()} ) r = Helpers.process_dev_response(_LOGGER, 'set_night_light', self, r_dict) if r is None: return False self.state.connection_status = ConnectionStatus.ONLINE self.state.nightlight_status = NightlightModes(mode.lower()) return True @property @deprecated('Use self.state.air_quality instead.') def air_quality(self) -> int | None: """Get air quality value PM2.5 (ug/m3).""" return self.state.pm25 @property @deprecated('Use self.state.fan_level instead.') def fan_level(self) -> int | None: """Get current fan level.""" return self.state.fan_level @property @deprecated('Use self.state.filter_life instead.') def filter_life(self) -> int | None: """Get percentage of filter life remaining.""" return self.state.filter_life @property @deprecated('Use self.state.display_status instead.') def display_state(self) -> bool: """Get display state. See [pyvesync.VeSyncAirBypass.display_status][`self.display_status`] """ return self.state.display_status == DeviceStatus.ON @property @deprecated('Use self.state.display_status instead.') def screen_status(self) -> bool: """Get display status. Returns: bool : True if display is on, False if off """ return self.state.display_status == DeviceStatus.ON class VeSyncAirBaseV2(VeSyncAirBypass): """Levoit V2 Air Purifier Class. Handles the Vital 100S/200S and Sprout Air Purifiers. The Sprout purifier has a separate class [VeSyncAirSprout][pyvesync.devices.vesyncpurifier.VeSyncAirSprout] that overrides the `_set_state` method. Inherits from VeSyncAirBypass and VeSyncBaseDevice class. For newer devices that use camel-case API calls. Args: details (dict): Dictionary of device details manager (VeSync): Instantiated VeSync object feature_map (PurifierMap): Device map template Attributes: state (PurifierState): State of the device. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. modes (list[str]): List of modes supported by the device. fan_levels (list[int]): List of fan levels supported by the device. nightlight_modes (list[str]): List of nightlight modes supported by the device. auto_preferences (list[str]): List of auto preferences supported by the device. """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: PurifierMap, ) -> None: """Initialize the VeSync Base API V2 Air Purifier Class.""" super().__init__(details, manager, feature_map) def _set_state(self, details: InnerPurifierBaseResult) -> None: """Set Purifier state from details response.""" if not isinstance(details, PurifierVitalDetailsResult): _LOGGER.debug('Invalid details model passed to _set_state') return self.state.connection_status = ConnectionStatus.ONLINE self.state.device_status = DeviceStatus.from_int(details.powerSwitch) self.state.mode = details.workMode self.state.filter_life = details.filterLifePercent if details.fanSpeedLevel == 255: # noqa: PLR2004 self.state.fan_level = 0 else: self.state.fan_level = details.fanSpeedLevel self.state.fan_set_level = details.manualSpeedLevel self.state.child_lock = bool(details.childLockSwitch) self.state.air_quality_level = details.AQLevel self.state.pm25 = details.PM25 self.state.light_detection_switch = DeviceStatus.from_int( details.lightDetectionSwitch ) self.state.light_detection_status = DeviceStatus.from_int( details.environmentLightState ) self.state.display_set_status = DeviceStatus.from_int(details.screenSwitch) self.state.display_status = DeviceStatus.from_int(details.screenState) auto_pref = details.autoPreference if auto_pref is not None: self.state.auto_preference_type = auto_pref.autoPreferenceType self.state.auto_room_size = auto_pref.roomSize self.state.pm1 = details.PM1 self.state.pm10 = details.PM10 self.state.aq_percent = details.AQPercent self.state.fan_rotate_angle = details.fanRotateAngle if details.filterOpenState is not None: self.state.filter_open_state = bool(details.filterOpenState) if details.timerRemain > 0: self.state.timer = Timer(details.timerRemain, 'off') @property @deprecated('Use self.state.fan_set_level instead.') def set_speed_level(self) -> int | None: """Get set speed level.""" return self.state.fan_set_level @property @deprecated("Use self.state.light_detection_switch, this returns 'on' or 'off") def light_detection(self) -> bool: """Return true if light detection feature is enabled.""" return self.state.light_detection_switch == DeviceStatus.ON @property @deprecated("Use self.state.light_detection_status, this returns 'on' or 'off'") def light_detection_state(self) -> bool: """Return true if light is detected.""" return self.state.light_detection_status == DeviceStatus.ON async def get_details(self) -> None: """Build API V2 Purifier details dictionary.""" r_dict = await self.call_bypassv2_api('getPurifierStatus') r_model = process_bypassv2_result( self, _LOGGER, 'get_details', r_dict, PurifierVitalDetailsResult ) if r_model is None: return self._set_state(r_model) @deprecated('Use toggle_light_detection(toggle) instead.') async def set_light_detection(self, toggle: bool) -> bool: """Set light detection feature.""" return await self.toggle_light_detection(toggle) async def toggle_light_detection(self, toggle: bool | None = None) -> bool: """Enable/Disable Light Detection Feature.""" if bool(self.state.light_detection_status) == toggle: _LOGGER.debug( 'Light detection is already %s', self.state.light_detection_status ) return True if toggle is None: toggle = not bool(self.state.light_detection_status) payload_data = {'lightDetectionSwitch': int(toggle)} r_dict = await self.call_bypassv2_api('setLightDetection', payload_data) r = Helpers.process_dev_response(_LOGGER, 'set_light_detection', self, r_dict) if r is None: return False self.state.light_detection_switch = DeviceStatus.from_bool(toggle) self.state.connection_status = ConnectionStatus.ONLINE return True async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = not bool(self.state.device_status) if not isinstance(toggle, bool): _LOGGER.debug('Invalid toggle value for purifier switch') return False if toggle == bool(self.state.device_status): _LOGGER.debug('Purifier is already %s', self.state.device_status) return True payload_data = {'powerSwitch': int(toggle), 'switchIdx': 0} r_dict = await self.call_bypassv2_api('setSwitch', payload_data) r = Helpers.process_dev_response(_LOGGER, 'toggle_switch', self, r_dict) if r is None: return False self.state.device_status = DeviceStatus.from_bool(toggle) self.state.connection_status = ConnectionStatus.ONLINE return True async def toggle_child_lock(self, toggle: bool | None = None) -> bool: """Levoit 100S/200S set Child Lock. Parameters: toggle (bool): True to turn child lock on, False to turn off Returns: bool : True if successful, False if not """ if toggle is None: toggle = not bool(self.state.child_lock) payload_data = {'childLockSwitch': int(toggle)} r_dict = await self.call_bypassv2_api('setChildLock', payload_data) r = Helpers.process_dev_response(_LOGGER, 'toggle_child_lock', self, r_dict) if r is None: return False self.state.child_lock = toggle self.state.connection_status = ConnectionStatus.ONLINE return True async def toggle_display(self, mode: bool) -> bool: if bool(self.state.light_detection_status): _LOGGER.error('Cannot set display when light detection is enabled') return False if bool(self.state.display_set_status) == mode: _LOGGER.debug('Display is already %s', mode) return True payload_data = {'screenSwitch': int(mode)} r_dict = await self.call_bypassv2_api('setDisplay', payload_data) r = Helpers.process_dev_response(_LOGGER, 'set_display', self, r_dict) if r is None: return False self.state.display_set_status = DeviceStatus.from_bool(mode) self.state.connection_status = ConnectionStatus.ONLINE return True async def set_timer(self, duration: int, action: str | None = None) -> bool: action = DeviceStatus.OFF # No other actions available for this device if action not in [DeviceStatus.ON, DeviceStatus.OFF]: _LOGGER.debug('Invalid action for timer') return False method = 'powerSwitch' action_int = 1 if action == DeviceStatus.ON else 0 action_item = PurifierV2TimerActionItems(type=method, act=action_int) timing = PurifierV2EventTiming(clkSec=duration) payload_data = PurifierV2TimerPayloadData( enabled=True, startAct=[action_item], tmgEvt=timing, ) r_dict = await self.call_bypassv2_api('addTimerV2', payload_data.to_dict()) r = Helpers.process_dev_response(_LOGGER, 'set_timer', self, r_dict) if r is None: return False r_model = ResultV2SetTimer.from_dict(r) self.state.timer = Timer(duration, action=action, id=r_model.id) self.state.connection_status = ConnectionStatus.ONLINE return True async def clear_timer(self) -> bool: if self.state.timer is None: _LOGGER.warning('No timer found, run get_timer() to retrieve timer.') return False payload_data = {'id': self.state.timer.id, 'subDeviceNo': 0} r_dict = await self.call_bypassv2_api('delTimerV2', payload_data) r = Helpers.process_dev_response(_LOGGER, 'clear_timer', self, r_dict) if r is None: return False self.state.timer = None self.state.connection_status = ConnectionStatus.ONLINE return True async def set_auto_preference( self, preference: str = PurifierAutoPreference.DEFAULT, room_size: int = 600 ) -> bool: """Set Levoit Vital 100S/200S auto mode. Parameters: preference (str | None | PurifierAutoPreference): Preference for auto mode, default 'default' (default, efficient, quiet) room_size (int | None): Room size in square feet, by default 600 """ if preference not in self.auto_preferences: _LOGGER.debug( '%s is invalid preference -' ' valid preferences are default, efficient, quiet', preference, ) return False payload_data = {'autoPreference': preference, 'roomSize': room_size} r_dict = await self.call_bypassv2_api('setAutoPreference', payload_data) r = Helpers.process_dev_response(_LOGGER, 'set_auto_preference', self, r_dict) if r is None: return False self.state.auto_preference_type = preference self.state.auto_room_size = room_size self.state.connection_status = ConnectionStatus.ONLINE return True async def set_fan_speed(self, speed: None | int = None) -> bool: if speed is not None: if speed not in self.fan_levels: _LOGGER.debug( '%s is invalid speed - valid speeds are %s', speed, str(self.fan_levels), ) return False new_speed = speed elif self.state.fan_level is None: new_speed = self.fan_levels[0] else: new_speed = Helpers.bump_level(self.state.fan_level, self.fan_levels) payload_data = {'levelIdx': 0, 'manualSpeedLevel': new_speed, 'levelType': 'wind'} r_dict = await self.call_bypassv2_api('setLevel', payload_data) r = Helpers.process_dev_response(_LOGGER, 'set_fan_speed', self, r_dict) if r is None: return False self.state.fan_set_level = new_speed self.state.fan_level = new_speed self.state.mode = PurifierModes.MANUAL self.state.device_status = DeviceStatus.ON self.state.connection_status = ConnectionStatus.ONLINE return True async def set_mode(self, mode: str) -> bool: 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 == PurifierModes.MANUAL: if self.state.fan_set_level is None or self.state.fan_level == 0: return await self.set_fan_speed(1) return await self.set_fan_speed(self.state.fan_set_level) payload_data = {'workMode': mode} r_dict = await self.call_bypassv2_api('setPurifierMode', payload_data) r = Helpers.process_dev_response(_LOGGER, 'mode_toggle', self, r_dict) if r is None: return False self.state.mode = mode self.state.connection_status = ConnectionStatus.ONLINE self.state.device_status = DeviceStatus.ON return True class VeSyncAirSprout(VeSyncAirBaseV2): # pylint: disable=too-many-ancestors """Class for the Sprout Air Purifier. Inherits from VeSyncAirBaseV2 class and overrides the _set_state method. See the [VeSyncAirBaseV2][pyvesync.devices.vesyncpurifier.VeSyncAirBaseV2] class for more information. Args: details (dict): Dictionary of device details manager (VeSync): Instantiated VeSync object feature_map (PurifierMap): Device map template Attributes: state (PurifierState): State of the device. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. modes (list[str]): List of modes supported by the device. fan_levels (list[int]): List of fan levels supported by the device. nightlight_modes (list[str]): List of nightlight modes supported by the device. auto_preferences (list[str]): List of auto preferences supported by the device. """ def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: PurifierMap, ) -> None: """Initialize air purifier class.""" super().__init__(details, manager, feature_map) def _set_state(self, details: InnerPurifierBaseResult) -> None: """Set Purifier state from details response.""" if not isinstance(details, PurifierSproutResult): _LOGGER.debug('Invalid details model passed to _set_state') return self.state.connection_status = ConnectionStatus.ONLINE self.state.device_status = DeviceStatus.from_int(details.powerSwitch) self.state.mode = details.workMode if details.fanSpeedLevel == 255: # noqa: PLR2004 self.state.fan_level = 0 else: self.state.fan_level = details.fanSpeedLevel self.state.fan_set_level = details.manualSpeedLevel self.state.child_lock = bool(details.childLockSwitch) self.state.air_quality_level = details.AQLevel self.state.pm25 = details.PM25 self.state.pm1 = details.PM1 self.state.pm10 = details.PM10 self.state.aq_percent = details.AQI self.state.display_set_status = DeviceStatus.from_int(details.screenSwitch) self.state.display_status = DeviceStatus.from_int(details.screenState) auto_pref = details.autoPreference if auto_pref is not None: self.state.auto_preference_type = auto_pref.autoPreferenceType self.state.auto_room_size = auto_pref.roomSize self.state.humidity = details.humidity self.state.temperature = int((details.temperature or 0) / 10) self.state.pm1 = details.PM1 self.state.pm10 = details.PM10 self.state.pm25 = details.PM25 self.state.voc = details.VOC self.state.co2 = details.CO2 if details.nightlight is not None: self.state.nightlight_status = DeviceStatus.from_int( details.nightlight.nightLightSwitch ) self.state.nightlight_brightness = details.nightlight.brightness async def get_details(self) -> None: """Build API V2 Purifier details dictionary.""" r_dict = await self.call_bypassv2_api('getPurifierStatus') r_model = process_bypassv2_result( self, _LOGGER, 'get_details', r_dict, PurifierSproutResult ) if r_model is None: return self._set_state(r_model) class VeSyncAir131(BypassV1Mixin, VeSyncPurifier): """Levoit Air Purifier Class. Class for LV-PUR131S, using BypassV1 API. Attributes: state (PurifierState): State of the device. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. modes (list[str]): List of modes supported by the device. fan_levels (list[int]): List of fan levels supported by the device. nightlight_modes (list[str]): List of nightlight modes supported by the device. auto_preferences (list[str]): List of auto preferences supported by the device. """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: PurifierMap, ) -> None: """Initialize air purifier class.""" super().__init__(details, manager, feature_map) def _set_state(self, details: Purifier131Result) -> None: """Set state from purifier API get_details() response.""" self.state.device_status = details.deviceStatus self.state.connection_status = details.connectionStatus self.state.active_time = details.activeTime self.state.filter_life = details.filterLife.percent self.state.display_status = DeviceStatus(details.screenStatus) self.state.display_set_status = details.screenStatus self.state.child_lock = bool(DeviceStatus(details.childLock)) self.state.mode = details.mode self.state.fan_level = details.level or 0 self.state.fan_set_level = details.level or 0 self.state.set_air_quality_level(details.airQuality) async def get_details(self) -> None: r_dict = await self.call_bypassv1_api( RequestPurifier131, method='deviceDetail', endpoint='deviceDetail' ) r = Helpers.process_dev_response(_LOGGER, 'get_details', self, r_dict) if r is None: return r_model = Purifier131Result.from_dict(r.get('result', {})) self._set_state(r_model) async def toggle_display(self, mode: bool) -> bool: update_dict = {'status': 'on' if mode else 'off'} r_dict = await self.call_bypassv1_api( RequestPurifier131, method='airPurifierScreenCtl', endpoint='airPurifierScreenCtl', update_dict=update_dict, ) r = Helpers.process_dev_response(_LOGGER, 'toggle_display', self, r_dict) if r is None: return False self.state.display_set_status = DeviceStatus.from_bool(mode) self.state.display_status = DeviceStatus.from_bool(mode) self.state.connection_status = ConnectionStatus.ONLINE return True async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.device_status != DeviceStatus.ON update_dict = {'status': DeviceStatus.from_bool(toggle).value} r_dict = await self.call_bypassv1_api( RequestPurifier131, method='airPurifierPowerSwitchCtl', endpoint='airPurifierPowerSwitchCtl', update_dict=update_dict, ) r = Helpers.process_dev_response(_LOGGER, 'toggle_switch', self, r_dict) if r is None: return False self.state.device_status = DeviceStatus.from_bool(toggle) self.state.connection_status = ConnectionStatus.ONLINE return True async def set_fan_speed(self, speed: int | None = None) -> bool: current_speed = self.state.fan_set_level or 0 if speed is not None: if speed not in self.fan_levels: _LOGGER.debug( '%s is invalid speed - valid speeds are %s', speed, str(self.fan_levels), ) return False new_speed = speed else: new_speed = Helpers.bump_level(current_speed, self.fan_levels) update_dict = {'level': new_speed} r_dict = await self.call_bypassv1_api( RequestPurifier131Level, method='airPurifierSpeedCtl', endpoint='airPurifierSpeedCtl', update_dict=update_dict, ) r = Helpers.process_dev_response(_LOGGER, 'set_fan_speed', self, r_dict) if r is None: return False self.state.fan_level = new_speed self.state.fan_set_level = new_speed self.state.connection_status = 'online' self.state.mode = PurifierModes.MANUAL return True async def set_mode(self, mode: str) -> bool: if mode not in self.modes: _LOGGER.debug('Invalid purifier mode used - %s', mode) return False if mode == PurifierModes.MANUAL: set_level = ( 1 if self.state.fan_set_level in [0, None] else self.state.fan_set_level ) return await self.set_fan_speed(set_level) update_dict = {'mode': mode} r_dict = await self.call_bypassv1_api( RequestPurifier131Mode, method='airPurifierRunModeCtl', endpoint='airPurifierRunModeCtl', update_dict=update_dict, ) r = Helpers.process_dev_response(_LOGGER, 'mode_toggle', self, r_dict) if r is None: return False self.state.mode = mode self.state.connection_status = ConnectionStatus.ONLINE return True webdjoe-pyvesync-eb8cecb/src/pyvesync/devices/vesyncswitch.py000066400000000000000000000350711507433633000250520ustar00rootroot00000000000000"""Classes for VeSync Switch Devices. This module provides classes for VeSync Switch Devices: 1. VeSyncSwitch: Abstract Base class for VeSync Switch Devices. 2. VeSyncWallSwitch: Class for VeSync Wall Switch Devices ESWL01 and ESWL03. 3. VeSyncDimmerSwitch: Class for VeSync Dimmer Switch Devices ESWD16. Attributes: feature_dict (dict): Dictionary of switch models and their supported features. Defines the class to use for each switch model and the list of features switch_modules (dict): Dictionary of switch models as keys and their associated classes as string values. Note: The switch device is built from the `feature_dict` dictionary and used by the `vesync.object_factory` during initial call to pyvesync.vesync.update() and determines the class to instantiate for each switch model. These classes should not be instantiated manually. """ from __future__ import annotations import logging from dataclasses import asdict from typing import TYPE_CHECKING from typing_extensions import deprecated from pyvesync.base_devices.switch_base import VeSyncSwitch from pyvesync.const import ConnectionStatus, DeviceStatus from pyvesync.models import switch_models from pyvesync.models.bypass_models import RequestBypassV1, TimerModels from pyvesync.utils.colors import Color from pyvesync.utils.device_mixins import BypassV1Mixin, process_bypassv1_result from pyvesync.utils.helpers import Helpers, Timer, Validators if TYPE_CHECKING: from pyvesync import VeSync from pyvesync.device_map import SwitchMap from pyvesync.models.vesync_models import ResponseDeviceDetailsModel _LOGGER = logging.getLogger(__name__) class VeSyncWallSwitch(BypassV1Mixin, VeSyncSwitch): """Etekcity standard wall switch. Inherits from the BypassV1Mixin and VeSyncSwitch classes. """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: SwitchMap ) -> None: """Initialize Etekcity Wall Switch class. Args: details (ResponseDeviceDetailsModel): The device details. manager (VeSync): The VeSync manager. feature_map (SwitchMap): The feature map for the device. """ super().__init__(details, manager, feature_map) async def get_details(self) -> None: r_dict = await self.call_bypassv1_api( RequestBypassV1, method='deviceDetail', endpoint='deviceDetail' ) r = Helpers.process_dev_response(_LOGGER, 'get_details', self, r_dict) if r is None: return resp_model = Helpers.model_maker( _LOGGER, switch_models.ResponseSwitchDetails, 'get_details', r, self ) if resp_model is None: return result = resp_model.result if not isinstance(result, switch_models.InternalSwitchResult): _LOGGER.warning('Invalid response model for switch details') return self.state.device_status = result.deviceStatus self.state.active_time = result.activeTime self.state.connection_status = result.connectionStatus async def toggle_switch(self, toggle: bool | None = None) -> bool: """Toggle switch device.""" if toggle is None: toggle = self.state.device_status != DeviceStatus.ON toggle_str = DeviceStatus.from_bool(toggle) r_dict = await self.call_bypassv1_api( switch_models.RequestSwitchStatus, {'status': toggle_str, 'switchNo': 0}, 'deviceStatus', 'deviceStatus', ) r = Helpers.process_dev_response(_LOGGER, 'get_details', self, r_dict) if r is None: return False self.state.device_status = DeviceStatus.from_bool(toggle) self.state.connection_status = ConnectionStatus.ONLINE return True async def get_timer(self) -> None: r_dict = await self.call_bypassv1_api( TimerModels.RequestV1GetTimer, method='getTimers', endpoint='timer/getTimers' ) if r_dict is None: return result_model = process_bypassv1_result( self, _LOGGER, 'get_timer', r_dict, TimerModels.ResultV1GetTimer ) if result_model is None: return timers = result_model.timers if not isinstance(timers, list) or len(timers) == 0: _LOGGER.debug('No timers found') return if len(timers) > 1: _LOGGER.debug('More than one timer found, using first timer') timer = timers[0] if not isinstance(timer, TimerModels.TimerItemV1): _LOGGER.warning('Invalid timer model') return self.state.timer = Timer( int(timer.counterTimer), action=timer.action, id=int(timer.timerID), ) async def set_timer(self, duration: int, action: str | None = None) -> bool: if action is None: action = ( DeviceStatus.ON if self.state.device_status == DeviceStatus.OFF else DeviceStatus.OFF ) if action not in [DeviceStatus.ON, DeviceStatus.OFF]: _LOGGER.warning('Invalid action for timer - on/off') return False update_dict = { 'action': action, 'counterTime': str(duration), } r_dict = await self.call_bypassv1_api( TimerModels.RequestV1SetTime, update_dict, method='addTimer', endpoint='timer/addTimer', ) if r_dict is None: return False result_model = process_bypassv1_result( self, _LOGGER, 'set_timer', r_dict, TimerModels.ResultV1SetTimer ) if result_model is None: return False self.state.timer = Timer( int(duration), action=action, id=int(result_model.timerID), ) return True async def clear_timer(self) -> bool: if self.state.timer is None: _LOGGER.warning('No timer set, run get_timer() first.') return False update_dict = { 'timerId': str(self.state.timer.id), } r_dict = await self.call_bypassv1_api( TimerModels.RequestV1ClearTimer, update_dict, method='deleteTimer', endpoint='timer/deleteTimer', ) if r_dict is None: return False result = Helpers.process_dev_response(_LOGGER, 'clear_timer', self, r_dict) if result is None: return False self.state.timer = None return True class VeSyncDimmerSwitch(BypassV1Mixin, VeSyncSwitch): """Vesync Dimmer Switch Class with RGB Faceplate. Inherits from the BypassV1Mixin and VeSyncSwitch classes. """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: SwitchMap ) -> None: """Initialize dimmer switch class. Args: details (ResponseDeviceDetailsModel): The device details. manager (VeSync): The VeSync manager. feature_map (SwitchMap): The feature map for the device. """ super().__init__(details, manager, feature_map) async def get_details(self) -> None: r_bytes = await self.call_bypassv1_api( switch_models.RequestDimmerDetails, method='deviceDetail', endpoint='deviceDetail', ) r = Helpers.process_dev_response(_LOGGER, 'get_details', self, r_bytes) if r is None: return resp_model = Helpers.model_maker( _LOGGER, switch_models.ResponseSwitchDetails, 'set_timer', r, self ) if resp_model is None: return result = resp_model.result if not isinstance(result, switch_models.InternalDimmerDetailsResult): _LOGGER.warning('Invalid response model for dimmer details') return self.state.active_time = result.activeTime self.state.connection_status = result.connectionStatus self.state.brightness = result.brightness self.state.backlight_status = result.rgbStatus new_color = result.rgbValue if isinstance(new_color, switch_models.DimmerRGB): self.state.backlight_color = Color.from_rgb( new_color.red, new_color.green, new_color.blue ) self.state.indicator_status = result.indicatorlightStatus self.state.device_status = result.deviceStatus @deprecated('switch_toggle() deprecated, use toggle_switch(toggle: bool | None)') async def switch_toggle(self, status: str) -> bool: """Toggle switch status.""" return await self.toggle_switch(status == DeviceStatus.ON) async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.device_status == 'off' toggle_status = DeviceStatus.from_bool(toggle) r_bytes = await self.call_bypassv1_api( switch_models.RequestDimmerStatus, {'status': toggle_status}, 'dimmerPowerSwitchCtl', 'dimmerPowerSwitchCtl', ) r = Helpers.process_dev_response(_LOGGER, 'toggle_switch', self, r_bytes) if r is None: return False self.state.device_status = toggle_status return True async def toggle_indicator_light(self, toggle: bool | None = None) -> bool: """Toggle indicator light.""" if toggle is None: toggle = self.state.indicator_status == 'off' toggle_status = DeviceStatus.from_bool(toggle) r_bytes = await self.call_bypassv1_api( switch_models.RequestDimmerStatus, {'status': toggle_status}, 'dimmerIndicatorLightCtl', 'dimmerIndicatorLightCtl', ) r = Helpers.process_dev_response(_LOGGER, 'toggle_indicator_light', self, r_bytes) if r is None: return False self.state.indicator_status = toggle_status return True async def set_backlight_status( self, status: bool, red: int | None = None, green: int | None = None, blue: int | None = None, ) -> bool: if red is not None and blue is not None and green is not None: new_color = Color.from_rgb(red, green, blue) else: new_color = None status_str = DeviceStatus.from_bool(status) update_dict: dict[str, str | dict] = {'status': status_str.value} if new_color is not None: update_dict['rgbValue'] = asdict(new_color.rgb) r_bytes = await self.call_bypassv1_api( switch_models.RequestDimmerStatus, update_dict, 'dimmerRgbValueCtl', 'dimmerRgbValueCtl', ) r = Helpers.process_dev_response(_LOGGER, 'set_rgb_backlight', self, r_bytes) if r is None: return False self.state.backlight_status = status_str if new_color is not None: self.state.backlight_color = new_color self.state.backlight_status = status_str self.state.device_status = DeviceStatus.ON self.state.connection_status = ConnectionStatus.ONLINE return True async def set_brightness(self, brightness: int) -> bool: """Set brightness of dimmer - 1 - 100.""" if not Validators.validate_zero_to_hundred(brightness): _LOGGER.warning('Invalid brightness - must be between 0 and 100') return False r_bytes = await self.call_bypassv1_api( switch_models.RequestDimmerBrightness, {'brightness': brightness}, 'dimmerBrightnessCtl', 'dimmerBrightnessCtl', ) r = Helpers.process_dev_response(_LOGGER, 'get_details', self, r_bytes) if r is None: return False self.state.brightness = brightness self.state.device_status = DeviceStatus.ON self.state.connection_status = ConnectionStatus.ONLINE return True async def get_timer(self) -> None: r_dict = await self.call_bypassv1_api( TimerModels.RequestV1GetTimer, method='getTimers', endpoint='timer/getTimers' ) result_model = process_bypassv1_result( self, _LOGGER, 'get_timer', r_dict, TimerModels.ResultV1GetTimer ) if result_model is None: return timers = result_model.timers if not isinstance(timers, list) or len(timers) == 0: _LOGGER.info('No timers found') return if len(timers) > 1: _LOGGER.debug('More than one timer found, using first timer') timer = timers[0] if not isinstance(timer, TimerModels.TimeItemV1): _LOGGER.warning('Invalid timer model') return self.state.timer = Timer( int(timer.counterTime), action=timer.action, id=int(timer.timerID), ) async def set_timer(self, duration: int, action: str | None = None) -> bool: if action is None: action = ( DeviceStatus.ON if self.state.device_status == DeviceStatus.OFF else DeviceStatus.OFF ) if action not in [DeviceStatus.ON, DeviceStatus.OFF]: _LOGGER.warning('Invalid action for timer - on/off') return False update_dict = {'action': action, 'counterTime': str(duration), 'status': '1'} r_dict = await self.call_bypassv1_api( TimerModels.RequestV1SetTime, update_dict, method='addTimer', endpoint='timer/addTimer', ) result_model = process_bypassv1_result( self, _LOGGER, 'set_timer', r_dict, TimerModels.ResultV1SetTimer ) if result_model is None: return False self.state.timer = Timer( int(duration), action=action, id=int(result_model.timerID), ) return True async def clear_timer(self) -> bool: if self.state.timer is None: _LOGGER.debug('No timer set, run get_timer() first.') return False update_dict = {'timerId': str(self.state.timer.id), 'status': '1'} r_dict = await self.call_bypassv1_api( TimerModels.RequestV1ClearTimer, update_dict, method='deleteTimer', endpoint='timer/deleteTimer', ) result = Helpers.process_dev_response(_LOGGER, 'clear_timer', self, r_dict) if result is None: return False self.state.timer = None return True webdjoe-pyvesync-eb8cecb/src/pyvesync/devices/vesyncthermostat.py000066400000000000000000000163221507433633000257410ustar00rootroot00000000000000"""Thermostat device classes.""" from __future__ import annotations import logging from typing import TYPE_CHECKING from pyvesync.base_devices.thermostat_base import VeSyncThermostat from pyvesync.const import ( ThermostatConst, ThermostatEcoTypes, ThermostatFanModes, ThermostatHoldStatus, ThermostatWorkModes, ) from pyvesync.models.thermostat_models import ( ResultThermostatDetails, ThermostatMinorDetails, ) from pyvesync.utils.device_mixins import BypassV2Mixin, process_bypassv2_result from pyvesync.utils.helpers import Helpers, Validators if TYPE_CHECKING: from pyvesync import VeSync from pyvesync.device_map import ThermostatMap from pyvesync.models.vesync_models import ResponseDeviceDetailsModel _LOGGER = logging.getLogger(__name__) class VeSyncAuraThermostat(BypassV2Mixin, VeSyncThermostat): """VeSync Aura Thermostat.""" def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: ThermostatMap, ) -> None: """Initialize VeSync Aura Thermostat.""" super().__init__(details, manager, feature_map) def _process_details(self, details: ResultThermostatDetails) -> None: """Internal method to process thermostat details.""" if ResultThermostatDetails.supportMode is not None: self.supported_work_modes = [ ThermostatWorkModes(mode) for mode in ResultThermostatDetails.supportMode ] self.state.work_mode = ThermostatConst.WorkMode(details.workMode) self.state.work_status = ThermostatConst.WorkStatus(details.workStatus) self.state.fan_status = ThermostatConst.FanStatus(details.fanStatus) self.state.fan_mode = ThermostatConst.FanMode(details.fanMode) self.state.temperature_unit = details.tempUnit self.state.temperature = details.temperature self.state.humidity = details.humidity self.state.heat_to_temp = details.heatToTemp self.state.cool_to_temp = details.coolToTemp self.state.deadband = details.deadband self.state.lock_status = details.lockStatus self.state.schedule_or_hold = ThermostatConst.ScheduleOrHoldOption( details.scheduleOrHold ) self.state.hold_end_time = details.holdEndTime self.state.hold_option = ThermostatConst.HoldOption(details.holdOption) self.state.eco_type = ThermostatConst.EcoType(details.ecoType) self.state.alert_status = details.alertStatus self.state.routine_running_id = details.routineRunningId self.state.routines = details.routines async def get_details(self) -> None: """Get the details of the thermostat.""" r_dict = await self.call_bypassv2_api('getTsStatus') r_model = process_bypassv2_result( self, _LOGGER, 'get_details', r_dict, ResultThermostatDetails ) if r_model is None: return self._process_details(r_model) async def get_configuration(self) -> None: """Get configuration or 'minor details'.""" r_dict = await self.call_bypassv2_api('getTsMinorInfo') result = process_bypassv2_result( self, _LOGGER, 'get_configuration', r_dict, ThermostatMinorDetails ) if result is None: return self.state.configuration = result async def _set_hold_status_api( self, key: str | None = None, temp: float | None = None, hold_status: ThermostatHoldStatus | None = None, ) -> bool: """Internal function to call the 'setHoldStatus' API.""" if key is None and temp is None and hold_status is None: _LOGGER.debug('At least one of key, temp, or hold_status must be provided.') return False payload_data: dict[str, str | float] = {} if hold_status is not None and hold_status.value == ThermostatHoldStatus.CANCEL: payload_data = {'holdStatus': ThermostatHoldStatus.CANCEL} else: if key is None or temp is None: _LOGGER.debug('Either key or temp must be provided.') return False payload_data = {key: temp, 'holdStatus': ThermostatHoldStatus.SET.value} r_dict = await self.call_bypassv2_api('setHoldStatus', data=payload_data) result = Helpers.process_dev_response(_LOGGER, 'setHoldStatus', self, r_dict) return bool(result) async def set_temp_point(self, temperature: float) -> bool: return await self._set_hold_status_api('setTempPoint', temperature) async def cancel_hold(self) -> bool: """Cancel hold.""" return await self._set_hold_status_api(hold_status=ThermostatHoldStatus.CANCEL) async def set_cool_to_temp(self, temperature: float) -> bool: """Set cool to temperature.""" return await self._set_hold_status_api('coolToTemp', temperature) async def set_heat_to_temp(self, temperature: float) -> bool: """Set heat to temperature.""" return await self._set_hold_status_api('heatToTemp', temperature) async def set_mode(self, mode: ThermostatWorkModes) -> bool: """Set thermostat mode.""" if mode not in self.supported_work_modes: _LOGGER.debug('Invalid mode: %s', mode) return False payload_data = {'tsMode': mode} r_dict = await self.call_bypassv2_api('setTsMode', data=payload_data) result = Helpers.process_dev_response(_LOGGER, 'setTsMode', self, r_dict) return bool(result) async def set_fan_mode(self, mode: ThermostatFanModes) -> bool: """Set thermostat fan mode.""" if mode not in self.fan_modes: _LOGGER.debug('Invalid fan mode: %s', mode) return False payload_data = {'fanMode': mode.value} r_dict = await self.call_bypassv2_api('setFanMode', data=payload_data) result = Helpers.process_dev_response(_LOGGER, 'setFanMode', self, r_dict) return bool(result) async def toggle_lock(self, toggle: bool, pin: int | str | None = None) -> bool: """Toggle thermostat lock status.""" if toggle is True and pin is None: _LOGGER.debug('PIN required to lock the thermostat.') return False payload_data: dict[str, bool | str] = {'lockStatus': toggle} if toggle is True: pin_int = int(pin) if isinstance(pin, str) else pin if not Validators.validate_range(pin_int, 0, 9999): _LOGGER.debug('PIN must be between 0 and 9999.') return False payload_data['lockPinCode'] = f'{pin_int:0>4}' r_dict = await self.call_bypassv2_api('setLockStatus', data=payload_data) result = Helpers.process_dev_response(_LOGGER, 'toggle_lock_status', self, r_dict) return bool(result) async def set_eco_type(self, eco_type: ThermostatEcoTypes) -> bool: """Set thermostat eco type.""" if eco_type not in self.eco_types: _LOGGER.debug('Invalid eco type: %s', eco_type) return False payload_data = {'ecoType': eco_type.value} r_dict = await self.call_bypassv2_api('setECOType', data=payload_data) result = Helpers.process_dev_response(_LOGGER, 'setEcoType', self, r_dict) return bool(result) webdjoe-pyvesync-eb8cecb/src/pyvesync/models/000077500000000000000000000000001507433633000216025ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/src/pyvesync/models/__init__.py000066400000000000000000000007321507433633000237150ustar00rootroot00000000000000"""Data models for VeSync API requests and responses. Models should follow the naming convention of Request/Response + API Name. Internal models can have any descriptive name. The `base_models` module contains the base classes for the VeSync API models, while the models for each device type and other API calls are stored in their respective modules. The [bypassv2_models][pyvesync.models.bypass_models] module contains the models for the common bypassV2 API calls. """ webdjoe-pyvesync-eb8cecb/src/pyvesync/models/base_models.py000066400000000000000000000062301507433633000244320ustar00rootroot00000000000000"""Base data models for API requests and response. These models are used to define the structure of the requests and responses from the API. They use Mashumaro for serialization and deserialization. The `DataClassConfigMixin` class sets default options for `orjson` and `Mashumaro`. Note: Dataclasses should follow the naming convention of Request/Response + API Name. Internal models can have any descriptive name. All models should inherit `ResponseBaseModel` or `RequestBaseModel`. Use [pyvesync.const][pyvesync.const] to set default values and import here. Attributes are inherited from the [const][pyvesync.const] module for default values. """ from __future__ import annotations from dataclasses import dataclass from time import time from typing import ClassVar import orjson from mashumaro.config import BaseConfig from mashumaro.mixins.orjson import DataClassORJSONMixin from pyvesync.const import ( APP_ID, APP_VERSION, BYPASS_HEADER_UA, CLIENT_TYPE, DEFAULT_LANGUAGE, DEFAULT_REGION, DEFAULT_TZ, MOBILE_ID, PHONE_BRAND, PHONE_OS, TERMINAL_ID, USER_TYPE, ) RequestHeaders = { 'User-Agent': BYPASS_HEADER_UA, 'Content-Type': 'application/json; charset=UTF-8', } class BaseModelConfig(BaseConfig): """Base config for dataclasses.""" orjson_options = orjson.OPT_NON_STR_KEYS @dataclass class RequestBaseModel(DataClassORJSONMixin): """Base request model for API requests. Forbids extra keys in the request JSON. """ class Config(BaseModelConfig): """orjson config for dataclasses.""" forbid_extra_keys = True orjson_options = orjson.OPT_NON_STR_KEYS @dataclass class ResponseBaseModel(DataClassORJSONMixin): """Base response model for API responses. Allows extra keys in the response model and non-string keys in the JSON. """ class Config(BaseConfig): """Config for dataclasses.""" orjson_options = orjson.OPT_NON_STR_KEYS forbid_extra_keys = False @dataclass class DefaultValues: """Default request fields. Attributes for the default values of the request fields and static methods for preparing calculated fields. """ _call_number: ClassVar[int] = 0 userType: str = USER_TYPE appVersion: str = APP_VERSION clientType: str = CLIENT_TYPE appId: str = APP_ID phoneBrand: str = PHONE_BRAND phoneOS: str = PHONE_OS mobileId: str = MOBILE_ID deviceRegion: str = DEFAULT_REGION countryCode: str = DEFAULT_REGION userCountryCode: str = DEFAULT_REGION acceptLanguage: str = DEFAULT_LANGUAGE timeZone: str = DEFAULT_TZ terminalId: str = TERMINAL_ID debugMode: bool = False @staticmethod def traceId() -> str: """Trace ID CSRF token.""" return str(int(time())) @staticmethod def newTraceId() -> str: """Generate a new trace ID.""" DefaultValues._call_number += 1 return f'APP{TERMINAL_ID[-5:-1]}{int(time())}-{DefaultValues._call_number:0>5}' @dataclass class ResponseCodeModel(ResponseBaseModel): """Model for the 'result' field in response.""" traceId: str code: int msg: str | None webdjoe-pyvesync-eb8cecb/src/pyvesync/models/bulb_models.py000066400000000000000000000105671507433633000244540ustar00rootroot00000000000000"""Models for VeSync Bulb API responses and requests. These models are used to serialize and deserialize the JSON responses from the VeSync API. The models are used in the VeSync API class methods to provide type hints and data validation. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Self, TypedDict from mashumaro import field_options from mashumaro.config import BaseConfig from mashumaro.mixins.orjson import DataClassORJSONMixin from pyvesync.models.base_models import ResponseBaseModel, ResponseCodeModel from pyvesync.models.bypass_models import BypassV2InnerResult, RequestBypassV1 @dataclass class JSONCMD(DataClassORJSONMixin): """Tunable Bulb JSON CMD dict.""" light: None | JSONCMDLight = None getLightStatus: None | str = None class Config(BaseConfig): """Configure the JSONCMD model.""" omit_none = True @dataclass class JSONCMDLight(DataClassORJSONMixin): """Light JSON CMD dict.""" action: str brightness: int | None = None colorTempe: int | None = None class Config(BaseConfig): """Configure the JSONCMDLight model.""" omit_none = True @dataclass class RequestESL100Detail(RequestBypassV1): """Request model for Etekcity bulb details.""" @dataclass class RequestESL100Status(RequestBypassV1): """Request model for Etekcity bulb details.""" status: str @dataclass class RequestESL100Brightness(RequestBypassV1): """Request model for Etekcity bulb details.""" status: str brightNess: int @dataclass class RequestESL100CWBase(RequestBypassV1): """Request model for ESL100CW bulb.""" jsonCmd: JSONCMD @dataclass class ResponseESL100Detail(ResponseCodeModel): """Response model for Etekcity bulb details.""" traceId: str code: int msg: str | None result: ResponseESL100DetailResult @dataclass class ResponseESL100DetailResult(ResponseBaseModel): """ESL100 Dimmable Bulb Device Detail Response.""" deviceName: str | None name: str | None brightness: int | None = field(metadata=field_options(alias='brightNess')) activeTime: int | None deviceStatus: str = 'off' connectionStatus: str = 'offline' @classmethod def __post_deserialize__( # type: ignore[override] cls, obj: Self ) -> Self: """Set values depending on color or white mode.""" if obj.brightness is None: obj.brightness = 0 if obj.activeTime is None: obj.activeTime = 0 return obj @dataclass class ResponseESL100CWDetail(ResponseCodeModel): """Response model for Etekcity bulb details.""" result: ResponseESL100CWDetailResult @dataclass class ResponseESL100CWLight(ResponseBaseModel): """ESL100CW Tunable Bulb Device Detail Response.""" brightness: int | None action: str = 'on' colorTempe: int = 0 @dataclass class ResponseESL100CWDetailResult(ResponseBaseModel): """Result model for ESL100CW Tunable bulb details.""" light: ResponseESL100CWLight @dataclass class ResponseESL100MCStatus(ResponseCodeModel): """Response model for Etekcity bulb status.""" result: ResponseESL100MCOuterResult @dataclass class ResponseESL100MCOuterResult: """ESL100MC Multi-Color Bulb Status Response.""" traceId: str code: int result: ResponseESL100MCResult @dataclass class ResponseESL100MCResult(BypassV2InnerResult): """ESL100MC Multi-Color Bulb Status Response.""" colorMode: str action: str brightness: int = 0 red: int = 0 green: int = 0 blue: int = 0 @dataclass class ResponseValcenoStatus(ResponseCodeModel): """Response model for Valceno bulb status.""" result: ResponseValcenoOuterResult @dataclass class ResponseValcenoOuterResult(ResponseBaseModel): """Valceno Bulb Status Response.""" result: ResponseValcenoStatusResult traceId: str = '' code: int = 0 @dataclass class ResponseValcenoStatusResult(ResponseBaseModel): """Valceno Bulb Status Result.""" colorMode: str = '' colorTemp: int = 0 brightness: int = 0 hue: int = 0 saturation: int = 0 value: int = 0 enabled: str = 'off' class ValcenoStatusPayload(TypedDict): """Typed Dict for setting Valceno bulb status.""" colorMode: str colorTemp: int | str brightness: int | str hue: int | str saturation: int | str value: int | str force: int webdjoe-pyvesync-eb8cecb/src/pyvesync/models/bypass_models.py000066400000000000000000000153771507433633000250350ustar00rootroot00000000000000"""Request Models for Bypass V2 Endpoints. API calls to bypassV2 endpoints have similar request structures. These models are used to serialize and deserialize the JSON requests for the bypassV2 endpoints. """ from __future__ import annotations from dataclasses import dataclass, field import orjson from mashumaro import pass_through from mashumaro.config import BaseConfig from mashumaro.mixins.orjson import DataClassORJSONMixin from pyvesync.models.base_models import ( BaseModelConfig, RequestBaseModel, ResponseBaseModel, ResponseCodeModel, ) @dataclass class RequestBypassV2(RequestBaseModel): """Bypass V2 Status Request Dict. This is the bypassV2 request model for API calls that use the `configModel` and `deviceId` fields. """ acceptLanguage: str accountID: str appVersion: str cid: str configModule: str debugMode: bool method: str phoneBrand: str phoneOS: str traceId: str timeZone: str token: str userCountryCode: str deviceId: str configModel: str payload: BypassV2RequestPayload @dataclass class BypassV2RequestPayload(RequestBaseModel): """Generic Bypass V2 Payload Request model.""" data: dict method: str source: str = 'APP' @dataclass class RequestBypassV1(RequestBaseModel): """Bypass V1 Status Request Dict. This is the bypassV1 request model for API calls that use the `configModel` and `deviceId` fields. """ acceptLanguage: str accountID: str appVersion: str cid: str configModule: str debugMode: bool method: str phoneBrand: str phoneOS: str traceId: str timeZone: str token: str userCountryCode: str uuid: str deviceId: str configModel: str class Config(BaseConfig): # type: ignore[override] """Configure omit None value keys.""" omit_none = True orjson_options = orjson.OPT_NON_STR_KEYS forbid_extra_keys = True @dataclass class ResponseBypassV1(ResponseCodeModel): """Bypass V1 Response Dict.""" result: BypassV1Result | None = field( default=None, metadata={ 'serialize': pass_through, 'deserialize': pass_through, }, ) @dataclass class BypassV1Result(DataClassORJSONMixin): """Bypass V1 Response Dict.""" @dataclass class ResponseBypassV2(ResponseCodeModel): """Bypass V2 Response Dict. This is the bypassV2 response model for API calls that use the `configModel` and `deviceId` fields. """ result: BypassV2OuterResult | None = None @dataclass class BypassV2InnerResult(DataClassORJSONMixin): """Inner Bypass V2 Result Data Model.""" class Config(BaseModelConfig): """Configure the Outer Result model.""" allow_deserialization_not_by_alias = True @dataclass class BypassV2OuterResult(DataClassORJSONMixin): """Bypass V2 Outer Result Data Model.""" code: int result: BypassV2InnerResult | None = field( default=None, metadata={ 'serialize': pass_through, 'deserialize': pass_through, }, ) class Config(BaseModelConfig): """Configure the Outer Result model.""" allow_deserialization_not_by_alias = True @dataclass class BypassV2ResultError(DataClassORJSONMixin): """Bypass V2 Result Error Data Model.""" msg: str @dataclass class ResultV2GetTimer(BypassV2InnerResult): """Inner result for Bypass V2 GetTimer method.""" timers: list[TimerItemV2] | None = None @dataclass class ResultV2SetTimer(BypassV2InnerResult): """Result for Bypass V2 SetTimer method.""" id: int # Bypass V1 Timer Models @dataclass class TimeItemV1(ResponseBaseModel): """Data model for Bypass V1 Timers.""" timerID: str counterTime: str action: str status: str resetTime: str uuid: str @dataclass class TimerItemV1(ResponseBaseModel): """Data model for Bypass V1 Timers.""" timerID: str counterTimer: str action: str status: str resetTime: str @dataclass class TimerItemV2(ResponseBaseModel): """Data model for Bypass V2 Timers.""" id: int remain: int action: str total: int @dataclass class ResultV1SetTimer(BypassV1Result): """Result model for setting Bypass V1 API timer.""" timerID: str conflictTimerIds: list[str] | None = None @dataclass class RequestV1ClearTimer(RequestBypassV1): """Request model for clearing Bypass V1 API outlet timer.""" timerId: str status: str | None = None @dataclass class ResultV1GetTimerList(BypassV1Result): """Get timers result for v1 API.""" timers: list[TimeItemV1] | list[TimerItemV1] | TimerItemV1 | None = None @dataclass class RequestV1SetTime(RequestBypassV1): """Request model for setting timer with counterTime.""" counterTime: str action: str status: str | None = None switchNo: int | None = None @dataclass class RequestV1GetTimer(RequestBypassV1): """Request model for getting timers from v1 API.""" switchNo: str | None = None @dataclass class RequestV1SetTimer(RequestBypassV1): """Request model for timer with counterTimer key. Attributes: counterTimer (str): The timer value in seconds. action (str): The action to perform (e.g., "on", "off"). switchNo (int | None): The switch number for the timer. """ counterTimer: str action: str switchNo: int | None = None class TimerModels: """Class holding all common timer models. Attributes: ResultV2GetTimer (ResultV2GetTimer): Result model for Bypass V2 GetTimer method. ResultV2SetTimer (ResultV2SetTimer): Result model for Bypass V2 SetTimer method. ResultV1SetTimer (ResultV1SetTimer): Result model V1 API for setting timer. ResultV1GetTimer (ResultV1GetTimerList): Get timers result for v1 API. TimeItemV1 (TimeItemV1): Data model for Bypass V1 Timers. TimerItemV1 (TimerItemV1): Data model for Bypass V1 Timers. TimerItemV2 (TimerItemV2): Data model for Bypass V2 Timers. RequestV1ClearTimer (RequestV1ClearTimer): Model for deleting timer. RequestV1SetTimer (RequestV1SetTimer): Model for timer with counterTimer key. RequestV1GetTimer (RequestV1GetTimer): Model for getting timers from v1 API. RequestV1SetTime (RequestV1SetTime): Model for setting timer with counterTime key. """ ResultV2GetTimer = ResultV2GetTimer ResultV2SetTimer = ResultV2SetTimer ResultV1SetTimer = ResultV1SetTimer ResultV1GetTimer = ResultV1GetTimerList TimeItemV1 = TimeItemV1 TimerItemV1 = TimerItemV1 TimerItemV2 = TimerItemV2 RequestV1ClearTimer = RequestV1ClearTimer RequestV1SetTimer = RequestV1SetTimer RequestV1GetTimer = RequestV1GetTimer RequestV1SetTime = RequestV1SetTime webdjoe-pyvesync-eb8cecb/src/pyvesync/models/fan_models.py000066400000000000000000000020211507433633000242560ustar00rootroot00000000000000"""Data models for VeSync Fans. These models inherit from `ResponseBaseModel` and `RequestBaseModel` from the `base_models` module. """ from __future__ import annotations from dataclasses import dataclass from mashumaro.mixins.orjson import DataClassORJSONMixin from pyvesync.models.bypass_models import ( BypassV2InnerResult, ) @dataclass class TowerFanResult(BypassV2InnerResult): """Tower Fan Result Model.""" powerSwitch: int workMode: str manualSpeedLevel: int fanSpeedLevel: int screenState: int screenSwitch: int oscillationSwitch: int oscillationState: int muteSwitch: int muteState: int timerRemain: int temperature: int errorCode: int scheduleCount: int displayingType: int | None = None sleepPreference: FanSleepPreferences | None = None @dataclass class FanSleepPreferences(DataClassORJSONMixin): """Fan Sleep Preferences.""" sleepPreferenceType: str oscillationSwitch: int fallAsleepRemain: int autoChangeFanLevelSwitch: int webdjoe-pyvesync-eb8cecb/src/pyvesync/models/fryer_models.py000066400000000000000000000013651507433633000246530ustar00rootroot00000000000000"""Data models for VeSync air fryers.""" from __future__ import annotations from dataclasses import dataclass from pyvesync.models.base_models import ResponseBaseModel @dataclass class ResultFryerDetails(ResponseBaseModel): """Result model for air fryer details.""" returnStatus: FryerCookingReturnStatus | FryerBaseReturnStatus | None = None @dataclass class FryerCookingReturnStatus(ResponseBaseModel): """Result returnStatus model for air fryer status.""" currentTemp: int cookSetTemp: int mode: str cookSetTime: int cookLastTime: int cookStatus: str tempUnit: str @dataclass class FryerBaseReturnStatus(ResponseBaseModel): """Result returnStatus model for air fryer status.""" cookStatus: str webdjoe-pyvesync-eb8cecb/src/pyvesync/models/home_models.py000066400000000000000000000116561507433633000244600ustar00rootroot00000000000000"""Home and Rooms request and response models. WORK IN PROGRESS - NOT IMPLEMENTED YET Dataclasses should follow the naming convention of Request/Response + + Model. Internal models should be named starting with IntResp/IntReqModel. Attributes: ResponseLoginResultModel: Model for the 'result' field in login response. ResponseLoginModel: Model for the login response. RequestLoginModel: Model for the login request. Notes: All models should inherit `ResponseBaseModel` or `RequestBaseModel`. Use `pyvesync.models.base_models.DefaultValues` to set default values. """ from __future__ import annotations from dataclasses import dataclass, field from pyvesync.models.base_models import ( DefaultValues, RequestBaseModel, ResponseCodeModel, ) @dataclass class RequestHomeModel(RequestBaseModel): """Request model for home data. Inherits from `RequestVeSyncInstanceMixin` to populate fields from the VeSync instance. The `VeSync` instance is passed as a keyword argument `RequestHomeModel(manager=instance)`. """ # Arguments set by Manager Instance by passing kw_argument manager # these fields should be set to init=False accountID: str token: str userCountryCode: str # Non-default constants method: str = 'getHomeList' # default values acceptLanguage: str = DefaultValues.acceptLanguage appVersion: str = DefaultValues.appVersion timeZone: str = DefaultValues.timeZone phoneBrand: str = DefaultValues.phoneBrand phoneOS: str = DefaultValues.phoneOS traceId: str = field(default_factory=DefaultValues.traceId) debugMode: bool = DefaultValues.debugMode @dataclass class ResponseHomeModel(ResponseCodeModel): """Model for the home data response. Inherits from `ResponseCodeModel`. The `ResponseCodeModel` class provides the `code` and `msg` fields. The `ResponseHomeModel` class provides the `result` field containing the home data. Attributes: result: dict: The home data. """ result: IntResponseHomeResultModel | IntResponseHomeInfoResultModel | None @dataclass class RequestHomeInfoModel(RequestBaseModel): """Request model for home room information. Inherits from `RequestVeSyncInstanceMixin` to populate fields from the VeSync instance. The `VeSync` instance is passed as a keyword argument `RequestHomeModel(manager=instance)`. """ # argument to pass in as positional or keyword argument homeId homeId: str # Arguments set by Manager Instance by passing kw_argument manager # these fields should be set to init=False accountID: str = field(init=False) token: str = field(init=False) userCountryCode: str = field(init=False) # Non-default constants method: str = 'getHomeDetail' # default values acceptLanguage: str = DefaultValues.acceptLanguage appVersion: str = DefaultValues.appVersion timeZone: str = DefaultValues.timeZone phoneBrand: str = DefaultValues.phoneBrand phoneOS: str = DefaultValues.phoneOS traceId: str = field(default_factory=DefaultValues.traceId) debugMode: bool = DefaultValues.debugMode @dataclass class ResponseHomeInfoModel(ResponseCodeModel): """Model for the home room information response. Inherits from `ResponseCodeModel`. The `ResponseCodeModel` class provides the `traceId`, `code`, and `msg` fields. The `ResponseHomeInfoModel` class provides the `result` field containing the home room data. Attributes: result: dict: The home room data. """ result: IntResponseHomeInfoResultModel @dataclass class IntResponseHomeInfoResultModel: """Internal model for the 'result' field in home room response.""" roomInfoList: list[IntResponseHomeListModel] @dataclass class IntResponseRoomListModel: """Internal model for the 'roomList' field in home response.""" roomID: str roomName: str deviceList: list[IntResponseRoomDeviceListModel] plantComformHumidityRangeLower: int | None = None plantComformHumidityRangeHigher: int | None = None # group_list = [] # TODO: Create group model # noqa: ERA001 @dataclass class IntResponseRoomDeviceListModel: """Internal model for the device list in room response.""" logicalDeviceType: int virDeviceType: int cid: str uuid: str subDeviceNo: int deviceName: str configModule: str deviceRegion: str deviceType: str type: str connectionType: str currentFirmwareVersion: str deviceStatus: str connectionStatus: str mode: str | None = None subDeviceType: str | None = None macid: str | None = None @dataclass class IntResponseHomeResultModel: """Internal model for the 'result' field in home response.""" homeList: list[IntResponseHomeListModel] @dataclass class IntResponseHomeListModel: """Internal model for the 'homeList' field in home response result.""" homeId: int homeName: str nickname: str | None = None webdjoe-pyvesync-eb8cecb/src/pyvesync/models/humidifier_models.py000066400000000000000000000114521507433633000256470ustar00rootroot00000000000000"""Data models for VeSync Humidifier devices. These models inherit from `ResponseBaseModel` and `RequestBaseModel` from the `base_models` module. The `InnerHumidifierBaseResult` class is used as a base class for the inner humidifier result models. The correct subclass is determined by the mashumaro discriminator. """ from __future__ import annotations from dataclasses import dataclass from typing import Annotated from mashumaro.config import BaseConfig from mashumaro.types import Alias from pyvesync.models.base_models import ( ResponseBaseModel, ResponseCodeModel, ) @dataclass class ResponseHumidifierBase(ResponseCodeModel): """Humidifier Base Response Dict.""" result: OuterHumidifierResult @dataclass class OuterHumidifierResult(ResponseBaseModel): """Humidifier Result Dict.""" code: int result: InnerHumidifierBaseResult @dataclass class InnerHumidifierBaseResult(ResponseBaseModel): """Base class for inner humidifier results model. All inner results models inherit from this class and are correctly subclassed by the mashumaro discriminator. """ class Config(BaseConfig): # type: ignore[override] """Configure the results model to use subclass discriminator.""" allow_deserialization_not_by_alias = True class BypassV2InnerErrorResult(InnerHumidifierBaseResult): """Inner Error Result Model.""" msg: str # Inner Result models for individual devices inherit from InnerHumidifierBaseResult # and are used to parse the response from the API. # The correct subclass is determined by the mashumaro discriminator @dataclass class ClassicLVHumidResult(InnerHumidifierBaseResult): """Classic 200S Humidifier Result Model. Inherits from InnerHumidifierBaseResult. """ enabled: bool mist_virtual_level: int mist_level: int mode: str display: Annotated[bool, Alias('indicator_light_status')] water_lacks: bool humidity: int | None = None humidity_high: bool = False automatic_stop_reach_target: bool = False water_tank_lifted: bool = False warm_enabled: bool = False warm_level: int | None = None night_light_brightness: int | None = None configuration: ClassicConfig | None = None @dataclass class ClassicConfig(ResponseBaseModel): """Classic 200S Humidifier Configuration Model.""" auto_target_humidity: int = 0 display: Annotated[bool, Alias('indicator_light_status')] = False automatic_stop: bool = False class Config(BaseConfig): # type: ignore[override] """Configure the results model to use subclass discriminator.""" allow_deserialization_not_by_alias = True forbid_extra_keys = False @dataclass class LV600SConfig(ResponseBaseModel): """LV 600S Humidifier Configuration Model.""" auto_target_humidity: int = 0 display: bool = False @dataclass class LV600SExtension(ResponseBaseModel): """LV 600S Humidifier Configuration Model.""" timer_remain: int = 0 schedule_count: int = 0 @dataclass class LV600SHumidResult(InnerHumidifierBaseResult): """LV600S Humidifier Result Model. Inherits from InnerHumidifierBaseResult. """ automatic_stop_reach_target: bool display: bool enabled: bool humidity: int humidity_high: bool mist_level: int mist_virtual_level: int mode: str water_lacks: bool water_tank_lifted: bool extension: LV600SExtension | None = None configuration: LV600SConfig | None = None # Models for the VeSync Superior 6000S Humidifier @dataclass class Superior6000SResult(InnerHumidifierBaseResult): """Superior 6000S Humidifier Result Model. Inherits from InnerHumidifierBaseResult. """ powerSwitch: int humidity: int targetHumidity: int virtualLevel: int mistLevel: int workMode: str waterLacksState: int waterTankLifted: int autoStopSwitch: int autoStopState: int screenSwitch: int screenState: int scheduleCount: int timerRemain: int errorCode: int autoPreference: int childLockSwitch: int filterLifePercent: int temperature: int dryingMode: Superior6000SDryingMode | None = None @dataclass class Superior6000SDryingMode(ResponseBaseModel): """Drying Mode Model for Superior 6000S Humidifier.""" dryingLevel: int autoDryingSwitch: int dryingState: int dryingRemain: int # Models for the Levoit 1000S Humidifier @dataclass class Levoit1000SResult(InnerHumidifierBaseResult): """Levoit 1000S Humidifier Result Model.""" powerSwitch: int humidity: int targetHumidity: int virtualLevel: int mistLevel: int workMode: str waterLacksState: int waterTankLifted: int autoStopSwitch: int autoStopState: int screenSwitch: int screenState: int scheduleCount: int timerRemain: int errorCode: int webdjoe-pyvesync-eb8cecb/src/pyvesync/models/outlet_models.py000066400000000000000000000104301507433633000250310ustar00rootroot00000000000000"""Data models for VeSync outlets.""" from __future__ import annotations from dataclasses import dataclass from mashumaro.mixins.orjson import DataClassORJSONMixin from pyvesync.models.base_models import ( RequestBaseModel, ResponseBaseModel, ResponseCodeModel, ) from pyvesync.models.bypass_models import ( RequestBypassV1, ) @dataclass class Response7AOutlet(ResponseBaseModel): """Response model for 7A outlet.""" activeTime: int energy: float deviceStatus: str power: float | str voltage: float | str @dataclass class ResponseEnergyHistory(ResponseCodeModel): """Response model for energy history.""" result: ResponseEnergyResult @dataclass class ResponseEnergyResult(ResponseBaseModel): """Response model for energy result.""" energyConsumptionOfToday: float costPerKWH: float maxEnergy: float totalEnergy: float energyInfos: list[EnergyInfo] @dataclass class EnergyInfo: """Energy Info list items.""" timestamp: int energyKWH: float money: float @dataclass class Response10ADetails(DataClassORJSONMixin): """Response model for Etekcity outlet details.""" code: int msg: str | None deviceStatus: str connectionStatus: str activeTime: int power: float voltage: float energy: float | None = None nightLightStatus: str | None = None nightLightAutoMode: str | None = None nightLightBrightness: int | None = None @dataclass class ResponseOldEnergy(ResponseCodeModel): """Response model for old energy history.""" energyConsumptionOfToday: float costPerKWH: float maxEnergy: float totalEnergy: float data: list[float] @dataclass class Response15ADetails(ResponseCodeModel): """Response for 15A Outlets.""" result: Response15AOutletResult @dataclass class Response15AOutletResult(ResponseBaseModel): """Response model for 15A outlet.""" deviceStatus: str connectionStatus: str activeTime: int power: float voltage: float energy: float | None = None nightLightStatus: str | None = None nightLightAutoMode: str | None = None nightLightBrightness: int | None = None @dataclass class Request15ADetails(RequestBypassV1): """Request data model for 15A outlet Details.""" @dataclass class RequestOutdoorStatus(RequestBypassV1): """Request model for outlet status.""" status: str switchNo: str @dataclass class RequestEnergyHistory(RequestBaseModel): """Request model for energy history.""" acceptLanguage: str appVersion: str accountID: str method: str phoneBrand: str phoneOS: str timeZone: str token: str traceId: str userCountryCode: str debugMode: bool homeTimeZone: str uuid: str @dataclass class Request15AStatus(RequestBypassV1): """Request data model for 15A outlet. Inherits from RequestBypassV1. """ status: str @dataclass class Request15ANightlight(RequestBypassV1): """Nightlight request data model for 15A Outlets. Inherits from RequestBypassV1. """ mode: str @dataclass class ResponseOutdoorDetails(ResponseCodeModel): """Response model for outdoor outlet.""" result: ResponseOutdoorOutletResult @dataclass class ResponseOutdoorOutletResult(ResponseBaseModel): """Response model for outdoor outlet.""" deviceStatus: str connectionStatus: str activeTime: int power: float voltage: float energy: float subDevices: list[ResponseOutdoorSubDevices] @dataclass class ResponseOutdoorSubDevices(ResponseBaseModel): """Response model for outdoor energy.""" subDeviceNo: int defaultName: str subDeviceName: str subDeviceStatus: str @dataclass class ResponseBSDGO1Details(ResponseCodeModel): """Response model for BSDGO1 outlet.""" result: ResponseBSDGO1OutletResult @dataclass class ResponseBSDGO1OutletResult(ResponseBaseModel): """Response model for BSDGO1 outlet.""" powerSwitch_1: int active_time: int connectionStatus: str code: int @dataclass class Timer7AItem(ResponseBaseModel): """Timer item for 7A outlet.""" timerID: str counterTimer: int action: str timerStatus: str @dataclass class ResultESW10Details(ResponseBaseModel): """Response model for ESW10 outlet.""" enabled: bool webdjoe-pyvesync-eb8cecb/src/pyvesync/models/purifier_models.py000066400000000000000000000132251507433633000253470ustar00rootroot00000000000000"""Data models for VeSync Purifiers. These models inherit from `ResponseBaseModel` and `RequestBaseModel` from the `base_models` module. The `InnerPurifierBaseResult` class is used as a base class for the inner purifier result models for all models and the mashumaro discriminator determines the correct subclass when deserializing. """ from __future__ import annotations from dataclasses import dataclass from mashumaro.config import BaseConfig from mashumaro.types import Discriminator from pyvesync.models.base_models import ( RequestBaseModel, ResponseBaseModel, ) from pyvesync.models.bypass_models import ( BypassV1Result, BypassV2InnerResult, RequestBypassV1, ) @dataclass class InnerPurifierBaseResult(BypassV2InnerResult): """Base class for inner purifier results model.""" class Config(BaseConfig): # type: ignore[override] """Configure the results model to use subclass discriminator.""" discriminator = Discriminator(include_subtypes=True) @dataclass class PurifierVitalDetailsResult(InnerPurifierBaseResult): """Vital 100S/200S and Everest Purifier Result Model.""" powerSwitch: int filterLifePercent: int workMode: str manualSpeedLevel: int fanSpeedLevel: int AQLevel: int PM25: int screenState: int childLockSwitch: int screenSwitch: int lightDetectionSwitch: int environmentLightState: int scheduleCount: int timerRemain: int efficientModeTimeRemain: int errorCode: int autoPreference: V2AutoPreferences | None = None fanRotateAngle: int | None = None filterOpenState: int | None = None PM1: int | None = None PM10: int | None = None AQPercent: int | None = None @dataclass class V2AutoPreferences: """Vital 100S/200S Auto Preferences.""" autoPreferenceType: str roomSize: int @dataclass class PurifierSproutResult(InnerPurifierBaseResult): """Sprout Purifier Result Model.""" powerSwitch: int workMode: str manualSpeedLevel: int | None fanSpeedLevel: int | None PM1: int | None PM25: int | None PM10: int | None screenState: int childLockSwitch: int screenSwitch: int scheduleCount: int timerRemain: int humidity: int | None AQI: int | None AQLevel: int | None temperature: int | None VOC: int | None CO2: int | None errorCode: int nightlight: PurifierNightlight | None = None autoPreference: V2AutoPreferences | None = None @dataclass class PurifierNightlight(ResponseBaseModel): """Purifier Nightlight Response Dict.""" nightLightSwitch: bool brightness: int colorTemperature: int @dataclass class PurifierCoreDetailsResult(InnerPurifierBaseResult): """Purifier inner Result Dict.""" enabled: bool filter_life: int mode: str level: int device_error_code: int levelNew: int | None = None air_quality: int | None = None display: bool | None = None child_lock: bool | None = None configuration: PurifierCoreDetailsConfig | None = None extension: dict | None = None air_quality_value: int | None = None night_light: str | None = None fan_rotate: str | None = None # Purifier Timer Models @dataclass class PurifierModifyTimerResult(InnerPurifierBaseResult): """Purifier inner Add Timer Result Dict.""" id: int @dataclass class PurifierGetTimerResult(InnerPurifierBaseResult): """Purifier inner Timer Result Dict.""" timers: list[ResponsePurifierTimerItems] | None @dataclass class ResponsePurifierTimerItems(ResponseBaseModel): """Purifier Timer Items Response Dict.""" id: int remain: int total: int action: str @dataclass class PurifierV2TimerPayloadData(RequestBaseModel): """Purifier Timer Payload Data Request Dict.""" enabled: bool startAct: list[PurifierV2TimerActionItems] tmgEvt: PurifierV2EventTiming | None = None type: int = 0 subDeviceNo: int = 0 repeat: int = 0 @dataclass class PurifierV2TimerActionItems(RequestBaseModel): """Purifier Timer Action Items Request Dict.""" type: str act: int num: int = 0 @dataclass class PurifierV2EventTiming(RequestBaseModel): """Purifier Event Timing Request Dict.""" clkSec: int # Internal Purifier Details Models @dataclass class PurifierCoreDetailsConfig(ResponseBaseModel): """Config dict in Core purifier details response.""" display: bool display_forever: bool auto_preference: None | PurifierCoreAutoConfig = None @dataclass class PurifierCoreAutoConfig(ResponseBaseModel): """Auto configuration Core dict in purifier details response.""" type: str room_size: int @dataclass class PurifierDetailsExtension(ResponseBaseModel): """Extension dict in purifier details response for Core 200/300/400.""" schedule_count: int timer_remain: int # LV - PUR131S Purifier Models @dataclass class RequestPurifier131(RequestBypassV1): """Purifier 131 Request Dict.""" status: str | None = None @dataclass class RequestPurifier131Mode(RequestBypassV1): """Purifier 131 Request Dict.""" mode: str @dataclass class RequestPurifier131Level(RequestBypassV1): """Purifier 131 Request Dict.""" level: int @dataclass class Purifier131Result(BypassV1Result): """Purifier 131 Details Response Dict.""" screenStatus: str filterLife: Purifier131Filter activeTime: int levelNew: int level: int | None mode: str airQuality: str deviceName: str childLock: str deviceStatus: str connectionStatus: str @dataclass class Purifier131Filter(ResponseBaseModel): """Filter details model for LV PUR131.""" change: bool useHour: int percent: int webdjoe-pyvesync-eb8cecb/src/pyvesync/models/switch_models.py000066400000000000000000000041631507433633000250240ustar00rootroot00000000000000"""Data models for VeSync switches. These models inherit from `ResponseBaseModel` and `RequestBaseModel` from the `base_models` module. """ from __future__ import annotations from dataclasses import dataclass from typing import Any import orjson from mashumaro.config import BaseConfig from pyvesync.models.base_models import ResponseBaseModel, ResponseCodeModel from pyvesync.models.bypass_models import RequestBypassV1 @dataclass class ResponseSwitchDetails(ResponseCodeModel): """Dimmer and Wall Switch Details Response Dict.""" result: InternalDimmerDetailsResult | InternalSwitchResult | None = None @dataclass class InternalSwitchResult(ResponseBaseModel): """Dimmer Status Response Dict.""" deviceStatus: str connectionStatus: str activeTime: int @dataclass class InternalDimmerDetailsResult(ResponseBaseModel): """Dimmer Details Result Dict.""" devicename: str brightness: int indicatorlightStatus: str rgbStatus: str rgbValue: DimmerRGB deviceStatus: str connectionStatus: str activeTime: int = 0 timer: Any | None = None schedule: Any | None = None deviceImg: str | None = None @dataclass class DimmerRGB: """Dimmer RGB Color Dict.""" red: int green: int blue: int @dataclass class RequestSwitchBase(RequestBypassV1): """Base Dimmer Request Dict. Inherits from RequestBypassV1 to include the common fields for all requests. """ @dataclass class RequestDimmerBrightness(RequestBypassV1): """Dimmer Status Request Dict.""" brightness: str @dataclass class RequestDimmerDetails(RequestBypassV1): """Dimmer Details Request Dict.""" @dataclass class RequestSwitchStatus(RequestBypassV1): """Dimmer Details Request Dict.""" status: str switchNo: int @dataclass class RequestDimmerStatus(RequestBypassV1): """Dimmer Status Request Dict.""" status: str rgbValue: dict | None = None class Config(BaseConfig): # type: ignore[override] """Dimmer Indicator Control Config Dict.""" omit_none = True omit_default = True orjson_options = orjson.OPT_NON_STR_KEYS webdjoe-pyvesync-eb8cecb/src/pyvesync/models/thermostat_models.py000066400000000000000000000104501507433633000257110ustar00rootroot00000000000000"""Data models for VeSync thermostats.""" from __future__ import annotations from dataclasses import dataclass, field from mashumaro import field_options from mashumaro.mixins.orjson import DataClassORJSONMixin from pyvesync.const import ThermostatConst from pyvesync.models.bypass_models import BypassV2InnerResult @dataclass class ResultThermostatDetails(BypassV2InnerResult): """Result model for thermostat details.""" supportMode: list[int] workMode: int workStatus: int fanMode: int fanStatus: int tempUnit: str temperature: float humidity: int heatToTemp: int coolToTemp: int lockStatus: bool scheduleOrHold: int holdEndTime: int holdOption: int deadband: int ecoType: int alertStatus: int routines: list[ThermostatSimpleRoutine] routineRunningId: int | None = None @dataclass class ThermostatSimpleRoutine(DataClassORJSONMixin): """Thermostat routine model.""" name: str routineId: int @dataclass class ThermostatRoutine(DataClassORJSONMixin): """Model for full thermostat routine.""" name: str routineId: int type: int heatToTemp: int coolToTemp: int heatFanMode: int coolFanMode: int usuallyMask: int sensorIds: list[str] @dataclass class ThermostatMinorDetails(DataClassORJSONMixin): """Model for thermostat minor details.""" mcu_version: str = field(metadata=field_options(alias='mcuVersion')) hvac_capacity: int = field(metadata=field_options(alias='hvacCapcity')) timestamp: int = field(metadata=field_options(alias='timeStamp')) time_zone: int = field(metadata=field_options(alias='timeZone')) offset_in_sec: int = field(metadata=field_options(alias='offsetInSec')) time_fmt: int = field(metadata=field_options(alias='timeFmt')) date_fmt: int = field(metadata=field_options(alias='dateFmt')) fan_delay_time: int = field(metadata=field_options(alias='fanDelayTime')) fan_circulation_time: int = field(metadata=field_options(alias='fanCirTime')) hvac_protect_time: int = field(metadata=field_options(alias='hvacProtecTime')) hvac_min_on_time: int = field(metadata=field_options(alias='hvacMinOnTime')) aux_min_on_time: int = field(metadata=field_options(alias='auxMinOnTime')) screen_brightness: int = field(metadata=field_options(alias='screenBrightness')) standby_timeout: int = field(metadata=field_options(alias='standbyTimeOut')) aux_low_temperature: int = field(metadata=field_options(alias='auxLowBalanceTemp')) aux_high_temperature: int = field(metadata=field_options(alias='auxHighBalanceTemp')) keytone: bool = field(metadata=field_options(alias='keyTone')) smart_schedule_enabled: bool = field( metadata=field_options(alias='smartScheduleEnabled') ) time_to_temp_enabled: bool = field(metadata=field_options(alias='timeToTempEnabled')) early_on_enabled: bool = field(metadata=field_options(alias='earlyOnEnabled')) reminder_list: list[ThermostatReminder] = field( metadata=field_options(alias='reminderList') ) alarm_list: list[ThermostatAlarm] = field(metadata=field_options(alias='alarmList')) @dataclass class ThermostatReminder(DataClassORJSONMixin): """Model for thermostat reminder.""" code: int enabled: bool frequency: int code_name: str | None = None last_maintenance_time: int | None = field( default=None, metadata=field_options(alias='lastMaintenTime') ) @classmethod def __post_deserialize__( # type: ignore[override] cls, obj: ThermostatReminder ) -> ThermostatReminder: """Post-deserialization processing.""" if isinstance(obj.code, int): obj.code_name = ThermostatConst.ReminderCode(obj.code).name return obj @dataclass class ThermostatAlarm(DataClassORJSONMixin): """Model for thermostat alarm.""" code: int enabled: bool code_name: str | None = None aux_runtime_limit: int | None = field( default=None, metadata=field_options(alias='auxRunTimeLimit') ) @classmethod def __post_deserialize__( # type: ignore[override] cls, obj: ThermostatAlarm ) -> ThermostatAlarm: """Post-deserialization processing.""" if obj.code is not None: obj.code_name = ThermostatConst.AlarmCode(obj.code).name return obj webdjoe-pyvesync-eb8cecb/src/pyvesync/models/vesync_models.py000066400000000000000000000304621507433633000250330ustar00rootroot00000000000000"""Models for general VeSync API requests and responses. Dataclasses should follow the naming convention of Request/Response + + Model. Internal models should be named starting with IntResp/IntReqModel. Note: All models should inherit `ResponseBaseModel` or `RequestBaseModel`. Use `pyvesync.models.base_models.DefaultValues` to set default values. There should be no repeating keys set in the child models. """ from __future__ import annotations import hashlib from dataclasses import dataclass, field from typing import Any from pyvesync.models.base_models import ( DefaultValues, RequestBaseModel, ResponseBaseModel, ResponseCodeModel, ) @dataclass class RequestGetTokenModel(RequestBaseModel): """Request model for requesting auth token (used for login).""" # Arguments to set email: str method: str password: str # default values acceptLanguage: str = DefaultValues.acceptLanguage accountID: str = '' authProtocolType: str = 'generic' clientInfo: str = DefaultValues.phoneBrand clientType: str = DefaultValues.clientType clientVersion: str = f'VeSync {DefaultValues.appVersion}' debugMode: bool = False osInfo: str = DefaultValues.phoneOS terminalId: str = DefaultValues.terminalId timeZone: str = DefaultValues.timeZone token: str = '' userCountryCode: str = DefaultValues.userCountryCode appID: str = DefaultValues.appId sourceAppID: str = DefaultValues.appId traceId: str = field(default_factory=DefaultValues.newTraceId) def __post_init__(self) -> None: """Hash the password field.""" self.password = self.hash_password(self.password) @staticmethod def hash_password(string: str) -> str: """Encode password.""" return hashlib.md5(string.encode('utf-8')).hexdigest() # noqa: S324 @dataclass class RespGetTokenResultModel(ResponseBaseModel): """Model for the 'result' field in auth response with authorizeCode and account ID. This class is referenced by the `ResponseAuthModel` class. """ accountID: str authorizeCode: str @dataclass class RequestLoginTokenModel(RequestBaseModel): """Request model for login.""" # Arguments to set method: str authorizeCode: str | None # default values acceptLanguage: str = DefaultValues.acceptLanguage accountID: str = '' clientInfo: str = DefaultValues.phoneBrand clientType: str = DefaultValues.clientType clientVersion: str = f'VeSync {DefaultValues.appVersion}' debugMode: bool = False emailSubscriptions: bool = False osInfo: str = DefaultValues.phoneOS terminalId: str = DefaultValues.terminalId timeZone: str = DefaultValues.timeZone token: str = '' bizToken: str | None = None regionChange: str | None = None userCountryCode: str = DefaultValues.userCountryCode traceId: str = field(default_factory=DefaultValues.newTraceId) def __post_serialize__(self, d: dict[Any, Any]) -> dict[Any, Any]: """Remove null keys.""" if d['regionChange'] is None: d.pop('regionChange') if d['authorizeCode'] is None: d.pop('authorizeCode') if d['bizToken'] is None: d.pop('bizToken') return d @dataclass class RespLoginTokenResultModel(ResponseBaseModel): """Model for the 'result' field in login response containing token and account ID. This class is referenced by the `ResponseLoginModel` class. """ accountID: str acceptLanguage: str countryCode: str token: str bizToken: str = '' currentRegion: str = '' @dataclass class ResponseLoginModel(ResponseCodeModel): """Model for the login response. Inherits from `BaseResultModel`. The `BaseResultModel` class provides the defaults "code" and "msg" fields for the response. Attributes: result: ResponseLoginResultModel The inner model for the 'result' field in the login response. Examples: ```python a = { "code": 0, "msg": "success", "stacktrace": null, "module": null, "traceId": "123456", "result": { "accountID": "123456", "acceptLanguage": "en", "countryCode": "US", "token": "abcdef1234567890" } } b = ResponseLoginModel.from_dict(a) account_id = b.result.accountId token = b.result.token ``` """ result: RespLoginTokenResultModel | RespGetTokenResultModel @dataclass class RequestDeviceListModel(RequestBaseModel): """Model for the device list request.""" token: str accountID: str timeZone: str = DefaultValues.timeZone method: str = 'devices' pageNo: int = 1 pageSize: int = 100 appVersion: str = DefaultValues.appVersion phoneBrand: str = DefaultValues.phoneBrand phoneOS: str = DefaultValues.phoneOS acceptLanguage: str = DefaultValues.acceptLanguage traceId: str = str(DefaultValues.traceId()) def _flatten_device_prop(d: dict[str, Any]) -> dict[str, Any]: """Flatten deviceProp field in device list response.""" if isinstance(d.get('deviceProp'), dict): device_prop = d['deviceProp'] if device_prop.get('powerSwitch') is not None: d['deviceStatus'] = 'on' if device_prop['powerSwitch'] == 1 else 'off' if device_prop.get('connectionStatus') is not None: d['connectionStatus'] = device_prop['connectionStatus'] if device_prop.get('wifiMac') is not None: d['macID'] = device_prop['wifiMac'] d.pop('deviceProp', None) return d @dataclass class ResponseDeviceDetailsModel(ResponseBaseModel): """Internal response model for each device in device list response. Populates the 'list' field in the `InternalDeviceListResult`. Certain Devices have device status information in the `deviceProp` or `extension` fields. This model flattens those fields into the `deviceStatus` and `connectionStatus` fields before deserialization. """ deviceRegion: str isOwner: bool deviceName: str cid: str connectionType: str deviceType: str type: str configModule: str uuid: str | None = None macID: str = '' mode: str = '' deviceImg: str = '' speed: str | None = None currentFirmVersion: str | None = None subDeviceType: str | None = None subDeviceList: str | None = None extension: InternalDeviceListExtension | None = None subDeviceNo: int | None = None deviceStatus: str = 'off' connectionStatus: str = 'offline' productType: str | None = None @classmethod def __pre_deserialize__(cls, d: dict[Any, Any]) -> dict[Any, Any]: """Perform device_list pre-deserialization processes. This performs the following: - Flattens the `deviceProp` field into `deviceStatus`, `connectionStatus` and `macID` - Sets `cid` to `uuid` or `macID` if null """ super().__pre_deserialize__(d) d = _flatten_device_prop(d) if d.get('cid') is None: d['cid'] = d.get('uuid') if d.get('uuid') is not None else d.get('macID') return d @dataclass class InternalDeviceListResult(ResponseBaseModel): """Internal model for the 'result' field in device list response. Notes: Used by the `ResponseDeviceListModel` class to populate result field. """ total: int pageSize: int pageNo: int list: list[ResponseDeviceDetailsModel] @dataclass class ResponseDeviceListModel(ResponseCodeModel): """Device list response model. Inherits from `BaseResultModel`. The `BaseResultModel` class provides the defaults "code" and "msg" fields for the response. Attributes: result: InternalDeviceListResult The inner model for the 'result' field in the device list response. module: str | None stacktrace: str | None Notes: See the `DeviceListResultModel` and `DeviceListDeviceModel` classes for the inner model of the 'result' field. """ result: InternalDeviceListResult @dataclass class InternalDeviceListExtension(ResponseBaseModel): """Internal Optional 'extension' field in device list response. Used by the `InnerRespDeviceListDevModel` class to populate the extension field in the device list response. """ airQuality: None | int airQualityLevel: None | int mode: None | str fanSpeedLevel: None | str @dataclass class RequestPID(RequestBaseModel): """Model for the PID request.""" method: str appVersion: str phoneBrand: str phoneOS: str traceId: str token: str accountID: str mobileID: str configModule: str region: str @dataclass class RequestFirmwareModel(RequestBaseModel): """Model for the firmware request.""" accountID: str timeZone: str token: str userCountryCode: str cidList: list[str] acceptLanguage: str = DefaultValues.acceptLanguage traceId: str = field(default_factory=DefaultValues.traceId) appVersion: str = DefaultValues.appVersion phoneBrand: str = DefaultValues.phoneBrand phoneOS: str = DefaultValues.phoneOS method: str = 'getFirmwareUpdateInfoList' debugMode: bool = False @dataclass class ResponseFirmwareModel(ResponseCodeModel): """Model for the firmware response.""" result: FirmwareResultModel @dataclass class FirmwareUpdateInfoModel(ResponseBaseModel): """Firmware update information model.""" currentVersion: str latestVersion: str releaseNotes: str pluginName: str isMainFw: bool @dataclass class FirmwareDeviceItemModel(ResponseBaseModel): """Model for the firmware device item in the firmware response.""" deviceCid: str deviceName: str code: int msg: str | None firmUpdateInfos: list[FirmwareUpdateInfoModel] @dataclass class FirmwareResultModel(ResponseBaseModel): """Model for the firmware response result.""" cidFwInfoList: list[FirmwareDeviceItemModel] @dataclass class RequestDeviceConfiguration(RequestBaseModel): """Model for the device configuration request.""" accountID: str token: str acceptLanguage: str = DefaultValues.acceptLanguage appVersion: str = f'VeSync {DefaultValues.appVersion}' debugMode: bool = False method: str = 'getAppConfigurationV2' phoneBrand: str = DefaultValues.phoneBrand phoneOS: str = DefaultValues.phoneOS timeZone: str = DefaultValues.timeZone traceId: str = field(default_factory=DefaultValues.traceId) userCountryCode: str = DefaultValues.userCountryCode categories: list[dict[str, str | bool]] = field( default_factory=lambda: [ { 'category': 'SupportedModelsV3', 'language': 'en', 'testMode': False, 'version': '', } ] ) recall: bool = False @dataclass class ResponseDeviceConfiguration(ResponseCodeModel): """Model for the device configuration response. Inherits from `BaseResultModel`. The `BaseResultModel` class provides the defaults "code" and "msg" fields for the response. Attributes: result: dict The inner model for the 'result' field in the device configuration response. """ result: dict[str, Any] @dataclass class ResultDeviceConfiguration(ResponseBaseModel): """Model for the device configuration result field. This class is referenced by the `ResponseDeviceConfiguration` class. """ configList: list[DeviceConfigurationConfigListItem] @dataclass class DeviceConfigurationConfigListItem(ResponseBaseModel): """Model for each item in the configList field of the device configuration result. This class is referenced by the `ResultDeviceConfiguration` class. """ category: str items: list[dict[str, Any]] @dataclass class DeviceConfigItem(ResponseBaseModel): """Model for each item in the configList field of the device configuration result. This class is referenced by the `DeviceConfigurationConfigListItem` class. """ itemKey: str itemValue: list[dict[str, Any]] @dataclass class DeviceConfigItemValue(ResponseBaseModel): """Model for each item in the configList field of the device configuration result. This class is referenced by the `DeviceConfigItem` class. """ productLineList: list webdjoe-pyvesync-eb8cecb/src/pyvesync/utils/000077500000000000000000000000001507433633000214575ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/src/pyvesync/utils/__init__.py000066400000000000000000000000671507433633000235730ustar00rootroot00000000000000"""Helper utilities and classes for the VeSync API.""" webdjoe-pyvesync-eb8cecb/src/pyvesync/utils/colors.py000066400000000000000000000204151507433633000233340ustar00rootroot00000000000000"""Data structures for handling colors.""" from __future__ import annotations import colorsys import logging from dataclasses import InitVar, dataclass from pyvesync.utils.helpers import Validators _LOGGER = logging.getLogger(__name__) @dataclass class RGB: """RGB color space dataclass, for internal use in `utils.colors.Color` dataclass. Does not perform any validation, it should not be used directly. Used as an attribute in the :obj:`pyvesync.helpers.Color` dataclass. This should only be used through the :obj:`Color` dataclass with the Color.from_rgb(red, green, blue) classmethod. Attributes: red (float): The red component of the RGB color. green (float): The green component of the RGB color. blue (float): The blue component of the RGB color. """ red: float green: float blue: float def __post_init__(self) -> None: """Convert to int.""" self.red = int(self.red) self.green = int(self.green) self.blue = int(self.blue) def __str__(self) -> str: """Return string representation.""" return f'RGB({self.red}, {self.green}, {self.blue})' def __repr__(self) -> str: """Return string representation.""" return f'RGB(red={self.red}, green={self.green}, blue={self.blue})' def to_tuple(self) -> tuple[float, float, float]: """Return RGB values as tuple.""" return self.red, self.green, self.blue def to_dict(self) -> dict[str, float]: """Return RGB values as dict.""" return { 'red': self.red, 'green': self.green, 'blue': self.blue, } @dataclass class HSV: """HSV color space dataclass, for internal use in `utils.colors.Color` dataclass. Does not perform any validation and should not be used directly, only by the `Color` dataclass through the Color.from_hsv(hue, saturation, value) classmethod or Color.rgb_to_hsv(red, green, blue) method. Attributes: hue (float): The hue component of the color, typically in the range [0, 360). saturation (float): The saturation component of the color, typically in the range [0, 1]. value (float): The value (brightness) component of the color, typically in the range [0, 1]. """ hue: float saturation: float value: float def __post_init__(self) -> None: """Convert to int.""" self.hue = int(self.hue) self.saturation = int(self.saturation) self.value = int(self.value) def __str__(self) -> str: """Return string representation.""" return f'HSV({self.hue}, {self.saturation}, {self.value})' def __repr__(self) -> str: """Return string representation.""" return f'HSV(hue={self.hue}, saturation={self.saturation}, value={self.value})' def to_tuple(self) -> tuple[float, float, float]: """Return HSV values as tuple.""" return self.hue, self.saturation, self.value def to_dict(self) -> dict[str, float]: """Return HSV values as dict.""" return { 'hue': self.hue, 'saturation': self.saturation, 'value': self.value, } @dataclass class Color: """Dataclass for color values. This class should be instantiated through the `from_rgb` or `from_hsv` classmethods. It will return a `Color` object with the appropriate color values in RGB and HSV. Args: color_object (HSV | RGB): Named tuple with color values. Attributes: hsv (namedtuple): hue (0-360), saturation (0-100), value (0-100) see [`HSV dataclass`][pyvesync.utils.colors.HSV] rgb (namedtuple): red (0-255), green (0-255), blue (0-255) see [`RGB dataclass`][pyvesync.utils.colors.RGB] """ color_object: InitVar[HSV | RGB] def __post_init__( self, color_object: HSV | RGB, ) -> None: """Check HSV or RGB Values and create named tuples.""" if isinstance(color_object, HSV): self.hsv = color_object self.rgb = self.hsv_to_rgb(*self.hsv.to_tuple()) elif isinstance(color_object, RGB): self.rgb = color_object self.hsv = self.rgb_to_hsv(*self.rgb.to_tuple()) def __str__(self) -> str: """Return string representation.""" return f'Color(hsv={self.hsv}, rgb={self.rgb})' def __repr__(self) -> str: """Return string representation.""" return f'Color(hsv={self.hsv}, rgb={self.rgb})' def as_dict(self) -> dict[str, dict]: """Return color values as dict.""" return { 'hsv': { 'hue': self.hsv.hue, 'saturation': self.hsv.saturation, 'value': self.hsv.value, }, 'rgb': { 'red': self.rgb.red, 'green': self.rgb.green, 'blue': self.rgb.blue, }, } @classmethod def from_rgb( cls, red: float | None, green: float | None, blue: float | None ) -> Color | None: """Create Color instance from RGB values. Args: red (NUMERIC_STRICT): The red component of the color, typically in the range [0, 255]. green (NUMERIC_STRICT): The green component of the color, typically in the range [0, 255]. blue (NUMERIC_STRICT): The blue component of the color, typically in the range [0, 255]. Returns: Color | None: A Color object with the appropriate color values in RGB and HSV, or None if the input values are invalid. """ if not Validators.validate_rgb(red, green, blue): _LOGGER.debug('Invalid RGB values') return None return cls(RGB(float(red), float(green), float(blue))) # type: ignore[arg-type] @classmethod def from_hsv( cls, hue: float | None, saturation: float | None, value: float | None ) -> Color | None: """Create Color instance from HSV values. Args: hue (float): The hue component of the color, in the range [0, 360). saturation (float): The saturation component of the color, typically in the range [0, 1]. value (float): The value (brightness) component of the color, typically in the range [0, 1]. Returns: Color | None: A Color object with the appropriate color values in RGB and HSV, or None if the input values are invalid. """ if not Validators.validate_hsv(hue, saturation, value): _LOGGER.debug('Invalid HSV values') return None return cls( HSV(float(hue), float(saturation), float(value)) # type: ignore[arg-type] ) @staticmethod def hsv_to_rgb(hue: float, saturation: float, value: float) -> RGB: """Convert HSV to RGB. Args: hue (float): The hue component of the color, in the range [0, 360). saturation (float): The saturation component of the color, in the range [0, 1]. value (float): The value (brightness) component of the color, in the range [0, 1]. Returns: RGB: An RGB dataclass with red, green, and blue components. """ return RGB( *tuple( round(i * 255, 0) for i in colorsys.hsv_to_rgb(hue / 360, saturation / 100, value / 100) ) ) @staticmethod def rgb_to_hsv(red: float, green: float, blue: float) -> HSV: """Convert RGB to HSV. Args: red (float): The red component of the color, in the range [0, 255]. green (float): The green component of the color, in the range [0, 255]. blue (float): The blue component of the color, in the range [0, 255]. Returns: HSV: An HSV dataclass with hue, saturation, and value components. """ hsv_tuple = colorsys.rgb_to_hsv(red / 255, green / 255, blue / 255) hsv_factors = [360, 100, 100] return HSV( float(round(hsv_tuple[0] * hsv_factors[0], 2)), float(round(hsv_tuple[1] * hsv_factors[1], 2)), float(round(hsv_tuple[2] * hsv_factors[2], 0)), ) webdjoe-pyvesync-eb8cecb/src/pyvesync/utils/device_mixins.py000066400000000000000000000275221507433633000246670ustar00rootroot00000000000000"""Mixins for Devices that have similar API requests. The `BypassV2Mixin` and `BypassV1Mixin` classes are used to send API requests to the Bypass V2 and Bypass V1 endpoints, respectively. The `BypassV2Mixin` class is used for devices that use the `/cloud/v2/deviceManaged/bypassV2` endpoint, while the `BypassV1Mixin` class is used for devices that use the `/cloud/v1/deviceManaged/{endpoint}` path. """ from __future__ import annotations from logging import Logger from typing import TYPE_CHECKING, ClassVar, TypeVar from mashumaro.mixins.orjson import DataClassORJSONMixin from pyvesync.const import ConnectionStatus from pyvesync.models.base_models import DefaultValues from pyvesync.models.bypass_models import ( RequestBypassV1, RequestBypassV2, ) from pyvesync.utils.errors import ErrorCodes, ErrorTypes, raise_api_errors from pyvesync.utils.helpers import Helpers from pyvesync.utils.logs import LibraryLogger if TYPE_CHECKING: from pyvesync import VeSync from pyvesync.base_devices import VeSyncBaseDevice from pyvesync.utils.errors import ResponseInfo T_MODEL = TypeVar('T_MODEL', bound=DataClassORJSONMixin) BYPASS_V1_PATH = '/cloud/v1/deviceManaged/' BYPASS_V2_BASE = '/cloud/v2/deviceManaged/' def process_bypassv1_result( device: VeSyncBaseDevice, logger: Logger, method: str, resp_dict: dict | None, model: type[T_MODEL], ) -> T_MODEL | None: """Process the Bypass V1 API response. This will gracefully handle errors in the response and error codes, logging them as needed. The return value is the built model. Args: device (VeSyncBaseDevice): The device object. logger (Logger): The logger to use for logging. method (str): The method used in the payload. resp_dict (dict | str): The api response. model (type[T_MODEL]): The model to use for the response. Returns: dict: The response data """ if not isinstance(resp_dict, dict) or 'code' not in resp_dict: LibraryLogger.log_device_api_response_error( logger, device.device_name, device.device_type, method, 'Error decoding JSON response', ) return None error_info = ErrorCodes.get_error_info(resp_dict['code']) device.last_response = error_info device.last_response.response_data = resp_dict if error_info.error_type != ErrorTypes.SUCCESS: _handle_bypass_error(logger, device, method, error_info, resp_dict['code']) return None result = resp_dict.get('result') if not isinstance(result, dict): return None return Helpers.model_maker(logger, model, method, result, device) def _handle_bypass_error( logger: Logger, device: VeSyncBaseDevice, method: str, error_info: ResponseInfo, code: int, ) -> None: """Process the outer result error code. Internal method for handling the error code in the response field used by the `process_bypassv1_result` and `process_bypassv2_result` method. Args: logger (Logger): The logger to use for logging. device (VeSyncBaseDevice): The device object. method (str): The method used in the payload. error_info (ResponseInfo): The error info object. code (int): The error code. Note: This will raise the appropriate exception based on the error code. See `pyvesync.utils.errors.ErrorCodes` for more information about the error codes and their meanings. """ raise_api_errors(error_info) LibraryLogger.log_device_return_code( logger, method, device.device_name, device.product_type, code, error_info.message, ) device.state.connection_status = ConnectionStatus.from_bool(error_info.device_online) def _get_inner_result( device: VeSyncBaseDevice, logger: Logger, method: str, resp_dict: dict, ) -> dict | None: """Process the code in the result field of Bypass V2.""" try: outer_result = resp_dict['result'] inner_result = outer_result['result'] code = int(outer_result['code']) except (ValueError, TypeError, KeyError): LibraryLogger.log_device_api_response_error( logger, device.device_name, device.device_type, method, 'Error processing bypass V2 API response result.', ) return None if code != 0: error_info = ErrorCodes.get_error_info(code) error_msg = f'{error_info.message}' if inner_result.get('msg') is not None: error_info.message = f'{error_info.message} - {inner_result["msg"]}' LibraryLogger.log_device_return_code( logger, method, device.device_name, device.product_type, code, error_msg, ) device.last_response = error_info return None return inner_result def process_bypassv2_result( device: VeSyncBaseDevice, logger: Logger, method: str, resp_dict: dict | None, model: type[T_MODEL], ) -> T_MODEL | None: """Process the Bypass V1 API response. This will gracefully handle errors in the response and error codes, logging them as needed. The return dictionary is the **inner** result value of the API response. Args: device (VeSyncBaseDevice): The device object. logger (Logger): The logger to use for logging. method (str): The method used in the payload. resp_dict (dict | str): The api response. model (type[T_MODEL]): The model to use for the response. Returns: T_MODEL: An instance of the inner result model. """ if not isinstance(resp_dict, dict) or 'code' not in resp_dict: LibraryLogger.log_device_api_response_error( logger, device.device_name, device.device_type, method, 'Error decoding JSON response', ) return None error_info = ErrorCodes.get_error_info(resp_dict['code']) device.last_response = error_info device.last_response.response_data = resp_dict if error_info.error_type != ErrorTypes.SUCCESS: _handle_bypass_error(logger, device, method, error_info, resp_dict['code']) return None result = _get_inner_result(device, logger, method, resp_dict) if not isinstance(result, dict): return None return Helpers.model_maker(logger, model, method, result, device) class BypassV2Mixin: """Mixin for bypass V2 API. Overrides the `_build_request` method and `request_keys` attribute for devices that use the Bypass V2 API- /cloud/v2/deviceManaged/bypassV2. """ if TYPE_CHECKING: manager: VeSync __slots__ = () request_keys: ClassVar[list[str]] = [ 'acceptLanguage', 'appVersion', 'phoneBrand', 'phoneOS', 'accountID', 'cid', 'configModule', 'debugMode', 'traceId', 'timeZone', 'token', 'userCountryCode', 'configModel', 'deviceId', ] def _build_request( self, payload_method: str, data: dict | None = None, method: str = 'bypassV2', ) -> RequestBypassV2: """Build API request body Bypass V2 endpoint. Args: payload_method (str): The method to use in the payload dict. data (dict | None): The data dict inside the payload value. method (str): The method to use in the outer body, defaults to bypassV2. """ body = Helpers.get_class_attributes(DefaultValues, self.request_keys) body.update(Helpers.get_class_attributes(self.manager, self.request_keys)) body.update(Helpers.get_class_attributes(self, self.request_keys)) body['method'] = method body['payload'] = {'method': payload_method, 'source': 'APP', 'data': data or {}} return RequestBypassV2.from_dict(body) async def call_bypassv2_api( self, payload_method: str, data: dict | None = None, method: str = 'bypassV2', endpoint: str = 'bypassV2', ) -> dict | None: """Send Bypass V2 API request. This uses the `_build_request` method to send API requests to the Bypass V2 API. Args: payload_method (str): The method to use in the payload dict. data (dict | None): The data to send in the request. method (str): The method to use in the outer body. endpoint (str | None): The last part of the API url, defaults to `bypassV2`, e.g. `/cloud/v2/deviceManaged/bypassV2`. Returns: bytes: The response from the API request. """ request = self._build_request(payload_method, data, method) endpoint = BYPASS_V2_BASE + endpoint resp_dict, _ = await self.manager.async_call_api( endpoint, 'post', request, Helpers.req_header_bypass() ) return resp_dict class BypassV1Mixin: """Mixin for bypass V1 API. Overrides the `_build_request` method and `request_keys` attribute for devices that use the Bypass V1 API- /cloud/v1/deviceManaged/bypass. The primary method to call is `call_bypassv1_api`, which is a wrapper for setting up the request body and calling the API. The `bypass` endpoint can also be overridden for specific API calls. """ if TYPE_CHECKING: manager: VeSync __slots__ = () request_keys: ClassVar[list[str]] = [ 'acceptLanguage', 'appVersion', 'phoneBrand', 'phoneOS', 'accountID', 'cid', 'configModule', 'debugMode', 'traceId', 'timeZone', 'token', 'userCountryCode', 'uuid', 'configModel', 'deviceId', ] def _build_request( self, request_model: type[RequestBypassV1], update_dict: dict | None = None, method: str = 'bypass', ) -> RequestBypassV1: """Build API request body for the Bypass V1 endpoint. Args: request_model (type[RequestBypassV1]): The request model to use. update_dict (dict | None): Additional keys to add on. method (str): The method to use in the outer body, defaults to bypass. Returns: RequestBypassV1: The request body for the Bypass V1 endpoint, the correct model is determined from the RequestBypassV1 discriminator. """ body = Helpers.get_class_attributes(DefaultValues, self.request_keys) body.update(Helpers.get_class_attributes(self.manager, self.request_keys)) body.update(Helpers.get_class_attributes(self, self.request_keys)) body['method'] = method body.update(update_dict or {}) return request_model.from_dict(body) async def call_bypassv1_api( self, request_model: type[RequestBypassV1], update_dict: dict | None = None, method: str = 'bypass', endpoint: str = 'bypass', ) -> dict | None: """Send ByPass V1 API request. This uses the `_build_request` method to send API requests to the Bypass V1 API. The endpoint can be overridden with the `endpoint` argument. Args: request_model (type[RequestBypassV1]): The request model to use. update_dict (dict): Additional keys to add on. method (str): The method to use in the outer body. endpoint (str | None): The last part of the url path, defaults to `bypass`, e.g. `/cloud/v1/deviceManaged/bypass`. Returns: bytes: The response from the API request. """ request = self._build_request(request_model, update_dict, method) url_path = BYPASS_V1_PATH + endpoint resp_dict, _ = await self.manager.async_call_api( url_path, 'post', request, Helpers.req_header_bypass() ) return resp_dict webdjoe-pyvesync-eb8cecb/src/pyvesync/utils/enum_utils.py000066400000000000000000000016021507433633000242140ustar00rootroot00000000000000"""Utilities to handle Enums.""" from __future__ import annotations import enum class IntEnumMixin(enum.IntEnum): """Mixin class to handle missing enum values. Adds __missing__ method using the `extend_enum` function to return a new enum member with the name "UNKNOWN" and the missing value. """ @classmethod def _missing_(cls: type[enum.IntEnum], value: object) -> enum.IntEnum: """Handle missing enum values by returning member with UNKNOWN name.""" for member in cls: if member.value == value: return member unknown_enum_val = int.__new__(cls, value) # type: ignore[call-overload] unknown_enum_val._name_ = 'UNKNOWN' unknown_enum_val._value_ = value # type: ignore[assignment] unknown_enum_val.__objclass__ = cls.__class__ # type: ignore[assignment] return unknown_enum_val webdjoe-pyvesync-eb8cecb/src/pyvesync/utils/errors.py000066400000000000000000000750501507433633000233540ustar00rootroot00000000000000"""Module holding VeSync API Error Codes and Response statuses. Error codes are pulled from the VeSync app source code. If there are unknown errors that are not found, please open an issue on GitHub. Errors are integers divided by 1000 and then multiplied by 1000 to get the base error code. It also tries to check if the absolute value matches as well. This is used by the `pyvesync.utils.helpers.Helpers.process_dev_response` method to retrieve response code information and store in the `last_response` device instance. The "check_device" key of the error dictionary is used to determine if the logger should emit a warning to the user for critical device errors, such as a short or voltage error. The ResponseInfo class is used to define information regarding the error code or indicate the request was successful. The ErrorTypes class is used to categorize the error types. This is a WIP and subject to change. Should not be used for anything other than the ErrorCodes error configuration. Example: Example of the error dictionary structure: ```python pyvesync.errors.ErrorCodes.get_error_info("-11201022") ResponseInfo( name="PASSWORD_ERROR", error_type=ErrorTypes.AUTHENTICATION, msg="Invalid password" critical_error=False, operational_error=False, device_online=None ) device.last_response # Returns the ResponseInfo object. ``` """ from __future__ import annotations from dataclasses import dataclass from enum import StrEnum from types import MappingProxyType from mashumaro.mixins.orjson import DataClassORJSONMixin @dataclass class ResponseInfo(DataClassORJSONMixin): """Class holding response information and error code definitions and lookup methods. Attributes: name (str): Name of the error error_type (ErrorTypes): Type of the error see `ErrorTypes` message (str): Message for the error critical_error (bool): A major error, such as a short or voltage error operational_error (bool): Device connected but API error device_online (bool | None): Device online status response_data (dict | None): Response data from API - populated by the process_dev_response method in the Helpers class and bypass mixin methods. """ name: str error_type: str message: str critical_error: bool = False operational_error: bool = False # Device connected but API error device_online: bool | None = None # Defaults to connected response_data: dict | None = None # Response data from API class ErrorTypes(StrEnum): """Error types for API return codes. Attributes: SUCCESS: Successful request AUTHENTICATION: Authentication error RATE_LIMIT: Rate limiting error SERVER_ERROR: Server and connection error REQUEST_ERROR: Error in Request parameters or method DEVICE_ERROR: Device operational error, device connected but cannot perform requested action CONFIG_ERROR: Configuration error in user profie DEVICE_OFFLINE: Device is offline, not connected UNKNOWN_ERROR: Unknown error BAD_RESPONSE: Bad response from API """ SUCCESS = 'success' AUTHENTICATION = 'auth_error' RATE_LIMIT = 'rate_limit_error' SERVER_ERROR = 'server_error' REQUEST_ERROR = 'request_error' DEVICE_ERROR = 'device_error' CONFIG_ERROR = 'config_error' DEVICE_OFFLINE = 'device_offline' UNKNOWN_ERROR = 'unknown_error' TOKEN_ERROR = 'token_error' BAD_RESPONSE = 'bad_response' CROSS_REGION = 'cross_region' class ErrorCodes: """Class holding error code definitions and lookup methods. Taken from VeSync app source code. Values are `ErrorInfo` dataclasses. Attributes: errors (MappingProxyType[str, ErrorInfo]): Dictionary of error codes and their meanings. Example: Error codes are taken from VeSync app source code, if there are unknown errors that are not found, please open an issue on GitHub. The "critical_error" key of the error dictionary is used to determine if the logger should emit a warning. Used for device issues that are critical, such as a short or voltage error. Each error dictionary is structured as follows: ``` ErrorInfo( name: str, error_type: ErrorType, message: str, critical_error: bool, operational_error: bool, device_online: bool ) ``` The `cls.critical_error(error_code)` method is used to determine if the error is a critical device error that should emit a warning. `cls.get_error_info(error_code)` method is used to return the error dictionary for a given error code. """ errors: MappingProxyType[str, ResponseInfo] = MappingProxyType( { '-11260022': ResponseInfo( 'CROSS_REGION_ERROR', ErrorTypes.CROSS_REGION, 'Cross region error', ), '11': ResponseInfo( 'DEVICE_OFFLINE', ErrorTypes.DEVICE_OFFLINE, 'Device offline', device_online=False, ), '4041004': ResponseInfo( 'DEVICE_OFFLINE', ErrorTypes.DEVICE_OFFLINE, 'Device offline', device_online=False, ), '-11203000': ResponseInfo( 'ACCOUNT_EXIST', ErrorTypes.AUTHENTICATION, 'Account already exists' ), '-11200000': ResponseInfo( 'ACCOUNT_FORMAT_ERROR', ErrorTypes.CONFIG_ERROR, 'Account format error' ), '-11202000': ResponseInfo( 'ACCOUNT_NOT_EXIST', ErrorTypes.AUTHENTICATION, 'Account does not exist' ), '-11300027': ResponseInfo( 'AIRPURGE_OFFLINE', ErrorTypes.DEVICE_OFFLINE, 'Device offline', device_online=False, ), '-16906000': ResponseInfo( 'REQUEST_TOO_FREQUENT', ErrorTypes.RATE_LIMIT, 'Request too frequent', operational_error=True, ), '-11902000': ResponseInfo( 'AUTHKEY_NOT_EXIST', ErrorTypes.CONFIG_ERROR, 'Authkey does not exist' ), '-11900000': ResponseInfo( 'AUTHKEY_PID_NOT_MATCH', ErrorTypes.DEVICE_ERROR, 'Authkey PID mismatch' ), '-11504000': ResponseInfo( 'AWAY_MAX', ErrorTypes.CONFIG_ERROR, 'Away maximum reached' ), '11014000': ResponseInfo( 'BYPASS_AIRPURIFIER_E2', ErrorTypes.DEVICE_ERROR, 'Air Purifier E2 error', critical_error=True, device_online=True, ), '11802000': ResponseInfo( 'BYPASS_AIRPURIFIER_MOTOR_ABNORMAL', ErrorTypes.DEVICE_ERROR, 'Air Purifier motor error', critical_error=True, device_online=True, ), '11504000': ResponseInfo( 'BYPASS_AWAY_MAX', ErrorTypes.CONFIG_ERROR, 'Away maximum reached' ), '11509000': ResponseInfo( 'BYPASS_AWAY_NOT_EXIST', ErrorTypes.CONFIG_ERROR, 'Away does not exist', ), '11908000': ResponseInfo( 'BYPASS_COOK_TIMEOUT', ErrorTypes.DEVICE_ERROR, 'Cook timeout error' ), '11909000': ResponseInfo( 'BYPASS_SMART_STOP', ErrorTypes.DEVICE_ERROR, 'Smart stop error', device_online=True, ), '11910000': ResponseInfo( 'BYPASS_LEFT_ZONE_COOKING', ErrorTypes.DEVICE_ERROR, 'Left zone cooking error', device_online=True, ), '11911000': ResponseInfo( 'BYPASS_RIGHT_ZONE_COOKING', ErrorTypes.DEVICE_ERROR, 'Right zone cooking error', device_online=True, ), '11912000': ResponseInfo( 'BYPASS_ALL_ZONE_COOKING', ErrorTypes.DEVICE_ERROR, 'All zone cooking error', device_online=True, ), '11916000': ResponseInfo( 'BYPASS_NTC_RIGHT_TOP_SHORT', ErrorTypes.DEVICE_ERROR, 'Right top short error', critical_error=True, device_online=True, ), '11917000': ResponseInfo( 'BYPASS_NTC_RIGHT_TOP_OPEN', ErrorTypes.DEVICE_ERROR, 'Right top open error', critical_error=True, device_online=True, ), '11918000': ResponseInfo( 'BYPASS_NTC_BOTTOM_SHORT', ErrorTypes.DEVICE_ERROR, 'Bottom short error', critical_error=True, device_online=True, ), '11919000': ResponseInfo( 'BYPASS_NTC_BOTTOM_OPEN', ErrorTypes.DEVICE_ERROR, 'Bottom open error', critical_error=True, device_online=True, ), '11924000': ResponseInfo( 'BYPASS_RIGHT_TEMP_FAULT', ErrorTypes.DEVICE_ERROR, 'Right temperature fault', critical_error=True, device_online=True, ), '11925000': ResponseInfo( 'BYPASS_ZONE_2_MOTOR_ABNORMAL', ErrorTypes.DEVICE_ERROR, 'Zone 2 motor error', critical_error=True, device_online=True, ), '11021000': ResponseInfo( 'BYPASS_DEVICE_END', ErrorTypes.DEVICE_ERROR, 'Device end error', critical_error=True, device_online=True, ), '11012000': ResponseInfo( 'BYPASS_DEVICE_RUNNING', ErrorTypes.DEVICE_ERROR, 'Device running error', critical_error=True, device_online=True, ), '11020000': ResponseInfo( 'BYPASS_DEVICE_STOP', ErrorTypes.DEVICE_ERROR, 'Device stop error', device_online=True, critical_error=True, ), '11901000': ResponseInfo( 'BYPASS_DOOR_OPEN', ErrorTypes.DEVICE_ERROR, 'Door open error', critical_error=True, device_online=True, ), '11006000': ResponseInfo( 'BYPASS_E1_OPEN', ErrorTypes.DEVICE_ERROR, 'Open circuit error', critical_error=True, device_online=True, ), '11007000': ResponseInfo( 'BYPASS_E2_SHORT', ErrorTypes.DEVICE_ERROR, 'Short circuit error', device_online=True, critical_error=True, ), '11015000': ResponseInfo( 'BYPASS_E3_WARM', ErrorTypes.DEVICE_ERROR, 'Warm error', critical_error=True, device_online=True, ), '11018000': ResponseInfo( 'BYPASS_SET_MIST_LEVEL', ErrorTypes.DEVICE_ERROR, 'Cannot set mist level error', device_online=True, ), '11019000': ResponseInfo( 'BYPASS_E6_VOLTAGE_LOW', ErrorTypes.DEVICE_ERROR, 'Low voltage error', critical_error=True, device_online=True, ), '11013000': ResponseInfo( 'BYPASS_E7_VOLTAGE', ErrorTypes.DEVICE_ERROR, 'Voltage error', critical_error=True, device_online=True, ), '11607000': ResponseInfo( 'BYPASS_HUMIDIFIER_ERROR_CONNECT_MSG', ErrorTypes.DEVICE_ERROR, 'Humidifier connect message error', ), '11317000': ResponseInfo( 'BYPASS_DIMMER_NCT', ErrorTypes.DEVICE_ERROR, 'Dimmer NCT error', critical_error=True, device_online=True, ), '11608000': ResponseInfo( 'BYPASS_HUMIDIFIER_ERROR_WATER_PUMP', ErrorTypes.DEVICE_ERROR, 'Humidifier water pump error', critical_error=True, device_online=True, ), '11609000': ResponseInfo( 'BYPASS_HUMIDIFIER_ERROR_FAN_MOTOR', ErrorTypes.DEVICE_ERROR, 'Humidifier fan motor error', critical_error=True, device_online=True, ), '11601000': ResponseInfo( 'BYPASS_HUMIDIFIER_ERROR_DRY_BURNING', ErrorTypes.DEVICE_ERROR, 'Dry burning error', critical_error=True, device_online=True, ), '11602000': ResponseInfo( 'BYPASS_HUMIDIFIER_ERROR_PTC', ErrorTypes.DEVICE_ERROR, 'Humidifier PTC error', critical_error=True, device_online=True, ), '11603000': ResponseInfo( 'BYPASS_HUMIDIFIER_ERROR_WARM_HIGH', ErrorTypes.DEVICE_ERROR, 'Humidifier warm high error', critical_error=True, device_online=True, ), '11604000': ResponseInfo( 'BYPASS_HUMIDIFIER_ERROR_WATER', ErrorTypes.DEVICE_ERROR, 'Humidifier water error', critical_error=True, device_online=True, ), '11907000': ResponseInfo( 'BYPASS_LOW_WATER', ErrorTypes.DEVICE_ERROR, 'Low water error', device_online=True, critical_error=True, ), '11028000': ResponseInfo( 'BYPASS_MOTOR_OPEN', ErrorTypes.DEVICE_ERROR, 'Motor open error', device_online=True, critical_error=True, ), '11017000': ResponseInfo( 'BYPASS_NOT_SUPPORTED', ErrorTypes.REQUEST_ERROR, 'Not supported error' ), '11905000': ResponseInfo( 'BYPASS_NO_POT', ErrorTypes.DEVICE_ERROR, 'No pot error', device_online=True, critical_error=True, ), '12001000': ResponseInfo( 'BYPASS_LACK_FOOD', ErrorTypes.DEVICE_ERROR, 'Lack of food error', device_online=True, critical_error=True, ), '12002000': ResponseInfo( 'BYPASS_JAM_FOOD', ErrorTypes.DEVICE_ERROR, 'Jam food error', device_online=True, critical_error=True, ), '12003000': ResponseInfo( 'BYPASS_BLOCK_FOOD', ErrorTypes.DEVICE_ERROR, 'Block food error', device_online=True, critical_error=True, ), '12004000': ResponseInfo( 'BYPASS_PUMP_FAIL', ErrorTypes.DEVICE_ERROR, 'Pump failure error', device_online=True, critical_error=True, ), '12005000': ResponseInfo( 'BYPASS_CALI_FAIL', ErrorTypes.DEVICE_ERROR, 'Calibration failure error', device_online=True, critical_error=True, ), '11611000': ResponseInfo( 'BYPASS_FILTER_TRAY_ERROR', ErrorTypes.DEVICE_ERROR, 'Filter tray error', critical_error=True, device_online=True, ), '11610000': ResponseInfo( 'BYPASS_VALUE_ERROR', ErrorTypes.DEVICE_ERROR, 'Value error', critical_error=True, device_online=True, ), '11022000': ResponseInfo( 'BYPASS_CANNOT_SET_LEVEL', ErrorTypes.DEVICE_ERROR, 'Cannot set level error', critical_error=False, device_online=True, ), '11023000': ResponseInfo( 'BYPASS_NTC_BOTTOM_OPEN', ErrorTypes.DEVICE_ERROR, 'NTC bottom open error', critical_error=True, device_online=True, ), '11024000': ResponseInfo( 'BYPASS_NTC_BOTTOM_SHORT', ErrorTypes.DEVICE_ERROR, 'NTC bottom short error', critical_error=True, device_online=True, ), '11026000': ResponseInfo( 'BYPASS_NTC_TOP_OPEN', ErrorTypes.DEVICE_ERROR, 'NTC top open error', critical_error=True, device_online=True, ), '11025000': ResponseInfo( 'BYPASS_NTC_TOP_SHORT', ErrorTypes.DEVICE_ERROR, 'NTC top short error', critical_error=True, device_online=True, ), '11027000': ResponseInfo( 'BYPASS_OPEN_HEAT_PIPE_OR_OPEN_FUSE', ErrorTypes.DEVICE_ERROR, 'Open heat pipe or fuse error', critical_error=True, device_online=True, ), '11906000': ResponseInfo( 'BYPASS_OVER_HEATED', ErrorTypes.DEVICE_ERROR, 'Overheated error', critical_error=True, device_online=True, ), '11000000': ResponseInfo( 'BYPASS_PARAMETER_INVALID', ErrorTypes.REQUEST_ERROR, 'Invalid bypass parameter', ), '11510000': ResponseInfo( 'BYPASS_SCHEDULE_CONFLICT', ErrorTypes.CONFIG_ERROR, 'Schedule conflict' ), '11502000': ResponseInfo( 'BYPASS_SCHEDULE_MAX', ErrorTypes.CONFIG_ERROR, 'Maximum number of schedules reached', ), '11507000': ResponseInfo( 'BYPASS_SCHEDULE_NOT_EXIST', ErrorTypes.CONFIG_ERROR, 'Schedule does not exist', ), '11503000': ResponseInfo( 'TIMER_MAX', ErrorTypes.CONFIG_ERROR, 'Maximum number of timers reached', ), '11508000': ResponseInfo( 'TIMER_NOT_EXIST', ErrorTypes.CONFIG_ERROR, 'Timer does not exist', ), '11605000': ResponseInfo( 'BYPASS_WATER_LOCK', ErrorTypes.DEVICE_ERROR, 'Water lock error', critical_error=True, device_online=True, ), '11029000': ResponseInfo( 'BYPASS_WIFI_ERROR', ErrorTypes.DEVICE_ERROR, 'WiFi error' ), '11902000': ResponseInfo( 'BY_PASS_ERROR_COOKING_158', ErrorTypes.DEVICE_ERROR, 'Error setting cook mode, air fryer is already cooking', device_online=True, ), '11035000': ResponseInfo( 'BYPASS_MOTOR_ABNORMAL_ERROR', ErrorTypes.DEVICE_ERROR, 'Motor abnormal error', critical_error=True, device_online=True, ), '11903000': ResponseInfo( 'BY_PASS_ERROR_NOT_COOK_158', ErrorTypes.DEVICE_ERROR, 'Error pausing, air fryer is not cooking', device_online=True, ), '-12001000': ResponseInfo( 'CONFIGKEY_EXPIRED', ErrorTypes.CONFIG_ERROR, 'Configkey expired' ), '-12000000': ResponseInfo( 'CONFIGKEY_NOT_EXIST', ErrorTypes.CONFIG_ERROR, 'Configkey does not exist', ), '-11305000': ResponseInfo( 'CONFIG_MODULE_NOT_EXIST', ErrorTypes.REQUEST_ERROR, 'Config module does not exist', ), '-11100000': ResponseInfo( 'DATABASE_FAILED', ErrorTypes.SERVER_ERROR, 'Database error' ), '-11101000': ResponseInfo( 'DATABASE_FAILED_ERROR', ErrorTypes.SERVER_ERROR, 'Database error' ), '-11306000': ResponseInfo( 'DEVICE_BOUND', ErrorTypes.CONFIG_ERROR, 'Device already associated with another account', ), '-11301000': ResponseInfo( 'DEVICE_NOT_EXIST', ErrorTypes.CONFIG_ERROR, 'Device does not exist', device_online=False, ), '-11300000': ResponseInfo( 'DEVICE_OFFLINE', ErrorTypes.DEVICE_OFFLINE, 'Device offline', device_online=False, ), '-11302000': ResponseInfo( 'DEVICE_TIMEOUT', ErrorTypes.DEVICE_ERROR, 'Device timeout', device_online=False, ), '-11304000': ResponseInfo( 'DEVICE_TIMEZONE_DIFF', ErrorTypes.CONFIG_ERROR, 'Device timezone difference', ), '-11303000': ResponseInfo( 'FIRMWARE_LATEST', ErrorTypes.CONFIG_ERROR, 'No firmware update available', ), '-11102000': ResponseInfo( 'INTERNAL_ERROR', ErrorTypes.SERVER_ERROR, 'Internal server error' ), '-11004000': ResponseInfo( 'METHOD_NOT_FOUND', ErrorTypes.REQUEST_ERROR, 'Method not found' ), '-11107000': ResponseInfo( 'MONGODB_ERROR', ErrorTypes.SERVER_ERROR, 'MongoDB error' ), '-11105000': ResponseInfo( 'MYSQL_ERROR', ErrorTypes.SERVER_ERROR, 'MySQL error' ), '88888888': ResponseInfo( 'NETWORK_DISABLE', ErrorTypes.SERVER_ERROR, 'Network disabled' ), '77777777': ResponseInfo( 'NETWORK_TIMEOUT', ErrorTypes.SERVER_ERROR, 'Network timeout' ), '4031005': ResponseInfo( 'NO_PERMISSION_7A', ErrorTypes.DEVICE_ERROR, 'No 7A Permissions' ), '-11201000': ResponseInfo( 'PASSWORD_ERROR', ErrorTypes.AUTHENTICATION, 'Invalid password' ), '-11901000': ResponseInfo( 'PID_NOT_EXIST', ErrorTypes.DEVICE_ERROR, 'PID does not exist' ), '-11106000': ResponseInfo( 'REDIS_ERROR', ErrorTypes.SERVER_ERROR, 'Redis error' ), '-11003000': ResponseInfo( 'REQUEST_HIGH', ErrorTypes.RATE_LIMIT, 'Rate limiting error' ), '-11005000': ResponseInfo( 'RESOURCE_NOT_EXIST', ErrorTypes.REQUEST_ERROR, 'No device with ID found', device_online=False, ), '-11108000': ResponseInfo('S3_ERROR', ErrorTypes.SERVER_ERROR, 'S3 error'), '-11502000': ResponseInfo( 'SCHEDULE_MAX', ErrorTypes.CONFIG_ERROR, 'Maximum number of schedules reached', ), '-11103000': ResponseInfo( 'SERVER_BUSY', ErrorTypes.SERVER_ERROR, 'Server busy' ), '-11104000': ResponseInfo( 'SERVER_TIMEOUT', ErrorTypes.SERVER_ERROR, 'Server timeout' ), '-11501000': ResponseInfo( 'TIMER_CONFLICT', ErrorTypes.DEVICE_ERROR, 'Timer conflict' ), '-11503000': ResponseInfo( 'TIMER_MAX', ErrorTypes.DEVICE_ERROR, 'Maximum number of timers reached' ), '-11500000': ResponseInfo( 'TIMER_NOT_EXIST', ErrorTypes.DEVICE_ERROR, 'Timer does not exist' ), '-11001000': ResponseInfo( 'TOKEN_EXPIRED', ErrorTypes.TOKEN_ERROR, 'Invalid token' ), '-999999999': ResponseInfo( 'UNKNOWN', ErrorTypes.SERVER_ERROR, 'Unknown error' ), '-11307000': ResponseInfo( 'UUID_NOT_EXIST', ErrorTypes.DEVICE_ERROR, 'Device UUID not found', device_online=False, ), '12102000': ResponseInfo( 'TEM_SENOR_ERROR', ErrorTypes.DEVICE_ERROR, 'Temperature sensor error', critical_error=True, device_online=True, ), '12103000': ResponseInfo( 'HUM_SENOR_ERROR', ErrorTypes.DEVICE_ERROR, 'Humidity sensor error', critical_error=True, device_online=True, ), '12101000': ResponseInfo( 'SENSOR_ERROR', ErrorTypes.DEVICE_ERROR, 'Sensor error', critical_error=True, device_online=True, ), '11005000': ResponseInfo( 'BYPASS_DEVICE_IS_OFF', ErrorTypes.DEVICE_ERROR, 'Device is offDevice is off', critical_error=True, device_online=True, ), } ) @classmethod def get_error_info(cls, error_code: str | int | None) -> ResponseInfo: """Return error dictionary for the given error code. Args: error_code (str | int): Error code to lookup. Returns: dict: Error dictionary for the given error code. Example: ```python ErrorCodes.get_error_info("-11201022") ErrorInfo( "PASSWORD_ERROR", ErrorTypes.AUTHENTICATION, "Invalid password" ) ``` """ try: if error_code is None: return ResponseInfo('UNKNOWN', ErrorTypes.UNKNOWN_ERROR, 'Unknown error') error_str = str(error_code) error_int = int(error_code) if error_str == '0': return ResponseInfo('SUCCESS', ErrorTypes.SUCCESS, 'Success') if error_str in cls.errors: return cls.errors[error_str] error_code = int(error_int / 1000) * 1000 return cls.errors[str(error_code)] except (ValueError, TypeError, KeyError): return ResponseInfo('UNKNOWN', ErrorTypes.UNKNOWN_ERROR, 'Unknown error') @classmethod def is_critical(cls, error_code: str | int) -> bool: """Check if error code is a device error. Args: error_code (str | int): Error code to check. Returns: bool: True if error code is a device error, False otherwise. """ error_info = cls.get_error_info(error_code) return bool(error_info.critical_error) class VeSyncError(Exception): """Base exception for VeSync errors. These are raised based on API response codes and exceptions that may be raised by the different handlers. """ class VeSyncLoginError(VeSyncError): """Exception raised for login authentication errors.""" def __init__(self, msg: str) -> None: """Initialize the exception with a message.""" super().__init__(msg) class VeSyncTokenError(VeSyncError): """Exception raised for VeSync API authentication errors.""" def __init__(self, msg: str | None = None) -> None: """Initialize the exception with a message.""" super().__init__( f'Token expired or invalid - {msg if msg else "Re-authentication required"}' ) class VeSyncServerError(VeSyncError): """Exception raised for VeSync API server errors.""" def __init__(self, msg: str) -> None: """Initialize the exception with a message.""" super().__init__(msg) class VeSyncRateLimitError(VeSyncError): """Exception raised for VeSync API rate limit errors.""" def __init__(self) -> None: """Initialize the exception with a message.""" super().__init__('VeSync API rate limit exceeded') class VeSyncAPIResponseError(VeSyncError): """Exception raised for malformed VeSync API responses.""" def __init__(self, msg: None | str = None) -> None: """Initialize the exception with a message.""" if msg is None: msg = 'Unexpected VeSync API response.' super().__init__(msg) class VeSyncAPIStatusCodeError(VeSyncError): """Exception raised for malformed VeSync API responses.""" def __init__(self, status_code: str | None = None) -> None: """Initialize the exception with a message.""" message = 'VeSync API returned an unknown status code' if status_code is not None: message = f'VeSync API returned status code {status_code}' super().__init__(message) def raise_api_errors(error_info: ResponseInfo) -> None: """Raise the appropriate exception for API error code. Called by `VeSync.async_call_api` method. Raises: VeSyncRateLimitError: Rate limit error VeSyncLoginError: Authentication error VeSyncTokenError: Token error VeSyncServerError: Server error """ match error_info.error_type: case ErrorTypes.RATE_LIMIT: raise VeSyncRateLimitError case ErrorTypes.AUTHENTICATION: raise VeSyncLoginError(error_info.message) case ErrorTypes.SERVER_ERROR: msg = ( f'{error_info.message} - ' 'Please report error to github.com/webdjoe/pyvesync/issues' ) raise VeSyncServerError(msg) webdjoe-pyvesync-eb8cecb/src/pyvesync/utils/helpers.py000066400000000000000000000542261507433633000235040ustar00rootroot00000000000000"""Helper functions for VeSync API.""" from __future__ import annotations import hashlib import logging import re import time from collections.abc import Iterator from dataclasses import InitVar, dataclass, field from typing import TYPE_CHECKING, Any, TypeVar import orjson from mashumaro.exceptions import InvalidFieldValue, MissingField, UnserializableField from mashumaro.mixins.orjson import DataClassORJSONMixin from typing_extensions import deprecated from pyvesync.const import ( APP_VERSION, BYPASS_HEADER_UA, DEFAULT_REGION, KELVIN_MAX, KELVIN_MIN, MOBILE_ID, PHONE_BRAND, PHONE_OS, USER_TYPE, ConnectionStatus, ) from pyvesync.utils.errors import ErrorCodes, ErrorTypes, ResponseInfo from pyvesync.utils.logs import LibraryLogger if TYPE_CHECKING: from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice from pyvesync.vesync import VeSync T = TypeVar('T') T_MODEL = TypeVar('T_MODEL', bound=DataClassORJSONMixin) _LOGGER = logging.getLogger(__name__) NUMERIC_OPT = float | str | None NUMERIC_STRICT = float | str REQUEST_T = dict[str, Any] class Validators: """Methods to validate input.""" @staticmethod def validate_range( value: NUMERIC_OPT, minimum: NUMERIC_STRICT, maximum: NUMERIC_STRICT ) -> bool: """Validate number is within range.""" if value is None: return False try: return float(minimum) <= float(value) <= float(maximum) except (ValueError, TypeError): return False @classmethod def validate_zero_to_hundred(cls, value: NUMERIC_OPT) -> bool: """Validate number is a percentage.""" return Validators.validate_range(value, 0, 100) @classmethod def validate_hsv( cls, hue: NUMERIC_OPT, saturation: NUMERIC_OPT, value: NUMERIC_OPT ) -> bool: """Validate HSV values.""" return ( cls.validate_range(hue, 0, 360) and cls.validate_zero_to_hundred(saturation) and cls.validate_zero_to_hundred(value) ) @classmethod def validate_rgb( cls, red: NUMERIC_OPT, green: NUMERIC_OPT, blue: NUMERIC_OPT ) -> bool: """Validate RGB values.""" return all(cls.validate_range(val, 0, 255) for val in (red, green, blue)) class Converters: """Helper functions to convert units.""" @staticmethod def color_temp_kelvin_to_pct(kelvin: int) -> int: """Convert Kelvin to percentage.""" return int((kelvin - KELVIN_MIN) / (KELVIN_MAX - KELVIN_MIN) * 100) @staticmethod def color_temp_pct_to_kelvin(pct: int) -> int: """Convert percentage to Kelvin.""" return int(KELVIN_MIN + ((pct / 100) * (KELVIN_MAX - KELVIN_MIN))) @staticmethod def temperature_kelvin_to_celsius(kelvin: int) -> float: """Convert Kelvin to Celsius.""" return kelvin - 273.15 @staticmethod def temperature_celsius_to_kelvin(celsius: float) -> int: """Convert Celsius to Kelvin.""" return int(celsius + 273.15) @staticmethod def temperature_fahrenheit_to_celsius(fahrenheit: float) -> float: """Convert Fahrenheit to Celsius.""" return (fahrenheit - 32) * 5.0 / 9.0 @staticmethod def temperature_celsius_to_fahrenheit(celsius: float) -> float: """Convert Celsius to Fahrenheit.""" return celsius * 9.0 / 5.0 + 32 class Helpers: """VeSync Helper Functions.""" @staticmethod def model_maker( logger: logging.Logger, model: type[T_MODEL], method_name: str, data: dict[str, Any], device: VeSyncBaseDevice | None = None, ) -> T_MODEL | None: """Create a model instance from a dictionary. This method catches common errors that occur when parsing the API response and returns None if the data is invalid. Enable debug or verbose logging to see more information. Args: logger (logging.Logger): Logger instance. model (type[T_MODEL]): Model class to create an instance of. method_name (str): Name of the method used in API call. device (VeSyncBaseDevice): Instance of VeSyncBaseDevice. data (dict[str, Any] | None): Dictionary to create the model from. Returns: T_MODEL: Instance of the model class. """ try: model_instance = model.from_dict(data) except (MissingField, UnserializableField, InvalidFieldValue) as err: LibraryLogger.log_mashumaro_response_error( logger, method_name, data, err, device, ) return None return model_instance @staticmethod def bump_level(level: T | None, levels: list[T]) -> T: """Increment level by one returning to first level if at last. Args: level (T | None): Current level. levels (list[T]): List of levels. """ if level in levels: idx = levels.index(level) if idx < len(levels) - 1: return levels[idx + 1] return levels[0] @staticmethod def try_json_loads(data: str | bytes | None) -> dict | None: """Try to load JSON data. Gracefully handle errors and return None if loading fails. Args: data (str | bytes | None): JSON data to load. Returns: dict | None: Parsed JSON data or None if loading fails. """ if data is None: return None try: return orjson.loads(data) except (orjson.JSONDecodeError, TypeError): return None @classmethod def process_dev_response( # noqa: C901,PLR0912 cls, logger: logging.Logger, method_name: str, device: VeSyncBaseDevice, r_dict: dict | None, ) -> dict | None: """Process JSON response from Bytes. Parses bytes and checks for errors common to all JSON responses, included checking the "code" key for non-zero values. Outputs error to passed logger with formatted string if an error is found. This also saves the response code information to the `device.last_response` attribute. Args: logger (logging.Logger): Logger instance. method_name (str): Method used in API call. r_dict (dict | None): JSON response from API. device (VeSyncBaseDevice): Instance of VeSyncBaseDevice. Returns: dict | None: Parsed JSON response or None if there was an error. """ device.state.update_ts() if r_dict is None: logger.error('No response from API for %s', method_name) device.last_response = ResponseInfo( name='INVALID_RESPONSE', error_type=ErrorTypes.BAD_RESPONSE, message=f'No response from API for {method_name}', ) return None error_code = ( r_dict.get('error', {}).get('code') if 'error' in r_dict else r_dict.get('code') ) new_msg = None # Get error codes from nested dictionaries. if error_code == 0: internal_codes = cls._get_internal_codes(r_dict) for code_tuple in internal_codes: if code_tuple[0] != 0: error_code = code_tuple[0] new_msg = code_tuple[1] break if isinstance(error_code, int): error_int = error_code elif isinstance(error_code, str): try: error_int = int(error_code) except ValueError: error_int = -999999999 else: error_int = -999999999 error_info = ErrorCodes.get_error_info(error_int) if new_msg is not None: if error_info.error_type == ErrorTypes.UNKNOWN_ERROR: error_info.message = new_msg else: error_info.message = f'{error_info.message} - {new_msg}' if error_info.device_online is False: device.state.connection_status = ConnectionStatus.OFFLINE LibraryLogger.log_device_return_code( logger, method_name, device.device_name, device.device_type, error_int, f'{error_info.error_type} - {error_info.name} {error_info.message}', ) device.last_response = error_info if error_int != 0: return None return r_dict @staticmethod def get_class_attributes(target_class: object, keys: list[str]) -> dict[str, Any]: """Find matching attributes, static methods, and class methods from list of keys. This function is case insensitive and will remove underscores from the keys before comparing them to the class attributes. The provided keys will be returned in the same format if found Args: target_class (object): Class to search for attributes keys (list[str]): List of keys to search for Returns: dict[str, Any]: Dictionary of keys and their values from the class """ alias_map = { 'userCountryCode': 'countrycode', 'deviceId': 'cid', 'homeTimeZone': 'timezone', 'configModel': 'configmodule', 'region': 'countrycode', } def normalize_name(name: str) -> str: """Normalize a string by removing underscores and making it lowercase.""" return re.sub(r'_', '', name).lower() def get_value(attr_name: str) -> str | float | None: """Get value from attribute.""" attr = getattr(target_class, attr_name) try: return attr() if callable(attr) else attr # type: ignore[no-any-return] except TypeError: return None result = {} normalized_keys = {normalize_name(key): key for key in keys} normalized_aliases = [normalize_name(key) for key in alias_map.values()] for attr_name in dir(target_class): normalized_attr = normalize_name(attr_name) if normalized_attr in normalized_keys: attr_val = get_value(attr_name) if attr_val is not None: result[normalized_keys[normalized_attr]] = attr_val if normalized_attr in normalized_aliases: attr_val = get_value(attr_name) if attr_val is not None: key_index = normalized_aliases.index(normalized_attr) key_val = list(alias_map.keys())[key_index] if key_val in keys: result[key_val] = attr_val return result @staticmethod def req_legacy_headers(manager: VeSync) -> dict[str, str]: """Build header for legacy api GET requests. Args: manager (VeSyncManager): Instance of VeSyncManager. Returns: dict: Header dictionary for api requests. Examples: >>> req_headers(manager) { 'accept-language': 'en', 'accountId': manager.account_id, 'appVersion': APP_VERSION, 'content-type': 'application/json', 'tk': manager.token, 'tz': manager.time_zone, } """ return { 'accept-language': 'en', 'accountId': manager.account_id, # type: ignore[dict-item] 'appVersion': APP_VERSION, 'content-type': 'application/json', 'tk': manager.token, # type: ignore[dict-item] 'tz': manager.time_zone, } @staticmethod def req_header_bypass() -> dict[str, str]: """Build header for api requests on 'bypass' endpoint. Returns: dict: Header dictionary for api requests. Examples: >>> req_header_bypass() { 'Content-Type': 'application/json; charset=UTF-8', 'User-Agent': BYPASS_HEADER_UA, } """ return { 'Content-Type': 'application/json; charset=UTF-8', 'User-Agent': BYPASS_HEADER_UA, } @staticmethod def _req_body_base(manager: VeSync) -> dict[str, str]: """Return universal keys for body of api requests. Args: manager (VeSyncManager): Instance of VeSyncManager. Returns: dict: Body dictionary for api requests. Examples: >>> req_body_base(manager) { 'timeZone': manager.time_zone, 'acceptLanguage': 'en', } """ return {'timeZone': manager.time_zone, 'acceptLanguage': 'en'} @staticmethod def _req_body_auth(manager: VeSync) -> REQUEST_T: """Keys for authenticating api requests. Args: manager (VeSyncManager): Instance of VeSyncManager. Returns: dict: Authentication keys for api requests. Examples: >>> req_body_auth(manager) { 'accountID': manager.account_id, 'token': manager.token, } """ return {'accountID': manager.account_id, 'token': manager.token} @staticmethod @deprecated('This is a legacy function and will be removed in a future release.') def _req_body_details() -> REQUEST_T: """Detail keys for api requests. This method is deprecated, use `get_class_attributes` instead. Returns: dict: Detail keys for api requests. Examples: >>> req_body_details() { 'appVersion': APP_VERSION, 'phoneBrand': PHONE_BRAND, 'phoneOS': PHONE_OS, 'traceId': str(int(time.time())), } """ return { 'appVersion': APP_VERSION, 'phoneBrand': PHONE_BRAND, 'phoneOS': PHONE_OS, 'traceId': str(int(time.time())), } @classmethod @deprecated('This is a legacy function and will be removed in a future release.') def req_body(cls, manager: VeSync, type_: str) -> REQUEST_T: # noqa: C901 """Builder for body of api requests. This method is deprecated, use `get_class_attributes` instead. Args: manager (VeSyncManager): Instance of VeSyncManager. type_ (str): Type of request to build body for. Returns: dict: Body dictionary for api requests. Note: The body dictionary will be built based on the type of request. The type of requests include: - login - devicestatus - devicelist - devicedetail - energy_week - energy_month - energy_year - bypass - bypassV2 - bypass_config """ body: REQUEST_T = cls._req_body_base(manager) if type_ == 'login': body |= cls._req_body_details() # pylint: disable=protected-access body |= { 'email': manager.auth._username, # noqa: SLF001 'password': cls.hash_password( manager.auth._password # noqa: SLF001 ), 'devToken': '', 'userType': USER_TYPE, 'method': 'login', } return body body |= cls._req_body_auth(manager) if type_ == 'devicestatus': return body body |= cls._req_body_details() if type_ == 'devicelist': body['method'] = 'devices' body['pageNo'] = '1' body['pageSize'] = '100' elif type_ == 'devicedetail': body['method'] = 'devicedetail' body['mobileId'] = MOBILE_ID elif type_ == 'energy_week': body['method'] = 'energyweek' body['mobileId'] = MOBILE_ID elif type_ == 'energy_month': body['method'] = 'energymonth' body['mobileId'] = MOBILE_ID elif type_ == 'energy_year': body['method'] = 'energyyear' body['mobileId'] = MOBILE_ID elif type_ == 'bypass': body['method'] = 'bypass' elif type_ == 'bypassV2': body['deviceRegion'] = DEFAULT_REGION body['method'] = 'bypassV2' elif type_ == 'bypass_config': body['method'] = 'firmwareUpdateInfo' return body @staticmethod def calculate_hex(hex_string: str) -> float: """Credit for conversion to itsnotlupus/vesync_wsproxy. Hex conversion for legacy outlet power and voltage. """ hex_conv = hex_string.split(':') return (int(hex_conv[0], 16) + int(hex_conv[1], 16)) / 8192 @staticmethod def hash_password(string: str) -> str: """Encode password.""" return hashlib.md5(string.encode('utf-8')).hexdigest() # noqa: S324 @staticmethod def _get_internal_codes(response: dict) -> list[tuple[int, str | None]]: """Get all error codes from nested dictionary. Args: response (dict): API response. Returns: list[int]: List of error codes. """ error_keys = ['error', 'code', 'device_error_code', 'errorCode'] def extract_all_error_codes( key: str, var: dict ) -> Iterator[tuple[int, str | None]]: """Find all error code keys in nested dictionary.""" if hasattr(var, 'items'): for k, v in var.items(): if k == key and int(v) != 0: msg = var.get('msg') or var.get('result', {}).get('msg') yield v, msg if isinstance(v, dict): yield from extract_all_error_codes(key, v) elif isinstance(v, list): for item in v: yield from extract_all_error_codes(key, item) errors = [] for error_key in error_keys: errors.extend(list(extract_all_error_codes(error_key, response))) return errors @dataclass(repr=False) class Timer: """Dataclass to hold state of timers. Note: This should be used by VeSync device instances to manage internal status, does not interact with the VeSync API. Args: timer_duration (int): Length of timer in seconds action (str): Action to perform when timer is done id (int): ID of timer, defaults to 1 remaining (int): Time remaining on timer in seconds, defaults to None _update_time (int): Last updated unix timestamp in seconds, defaults to None Attributes: update_time (str): Timestamp of last update status (str): Status of timer, one of 'active', 'paused', 'done' time_remaining (int): Time remaining on timer in seconds running (bool): True if timer is running paused (bool): True if timer is paused done (bool): True if timer is done """ timer_duration: int action: str id: int = 1 remaining: InitVar[int | None] = None _status: str = field(default='active', init=False, repr=False) _remain: int = field(default=0, init=False, repr=False) _update_time: int = int(time.time()) def __post_init__(self, remaining: int | None) -> None: """Set remaining time if provided.""" if remaining is not None: self._remain = remaining else: self._remain = self.timer_duration def __repr__(self) -> str: """Return string representation of the Timer object. Returns: str: String representation of the Timer object. """ return ( f'Timer(id={self.id}, duration={self.timer_duration}, ' f'status={self.status}, remaining={self.time_remaining})' ) def update_ts(self) -> None: """Update timestamp.""" self._update_time = int(time.time()) @property def status(self) -> str: """Return status of timer.""" if self._status in ('paused', 'done'): return self._status if self.time_remaining <= 0: self._status = 'done' return 'done' return 'active' @property def time_remaining(self) -> int: """Return remaining seconds.""" if self._status == 'paused': return self._remain if self._status == 'done': return 0 # 'active' state - compute how much time has ticked away elapsed = time.time() - self._update_time current_remaining = self._remain - elapsed # If we've run out of time, mark it done if current_remaining <= 0: return 0 return int(current_remaining) @property def running(self) -> bool: """Check if timer is active.""" return self.time_remaining > 0 and self.status == 'active' @property def paused(self) -> bool: """Check if timer is paused.""" return bool(self.status == 'paused') @property def done(self) -> bool: """Check if timer is complete.""" return bool(self.time_remaining <= 0 or self._status == 'done') def end(self) -> None: """Change status of timer to done.""" self._status = 'done' self._remain = 0 def start(self) -> None: """Restart paused timer.""" if self._status != 'paused': return self._update_time = int(time.time()) self._status = 'active' def pause(self) -> None: """Pauses the timer if it's active. Performs the following steps: - Calculate the up-to-date remaining time, - Update internal counters, - Set _status to 'paused'. """ if self._status == 'active': # Update the time_remaining based on elapsed current_remaining = self.time_remaining if current_remaining <= 0: self._status = 'done' self._remain = 0 else: self._status = 'paused' self._remain = current_remaining self._update_time = int(time.time()) webdjoe-pyvesync-eb8cecb/src/pyvesync/utils/logs.py000066400000000000000000000445161507433633000230070ustar00rootroot00000000000000"""library_logger.py. This module provides a custom logger for libraries. It automatically captures the caller's module and class name in the log records, and it provides helper methods for logging API calls and other specially formatted messages. Usage: from library_logger import LibraryLogger # Use `LibraryLogger.log_api_call` to log API calls. LibraryLogger.log_api_call(logger, response) # Use `LibraryLogger.log_api_exception` to log API exceptions. LibraryLogger.log_api_exception(logger, request_dict, exception) """ from __future__ import annotations import logging import os import re from collections.abc import Mapping from dataclasses import fields, is_dataclass from pathlib import Path from typing import TYPE_CHECKING import orjson from mashumaro.exceptions import InvalidFieldValue, MissingField, UnserializableField from multidict import CIMultiDictProxy if TYPE_CHECKING: from aiohttp import ClientResponse from aiohttp.client_exceptions import ClientResponseError from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice class LibraryLogger: """Library Logging Interface. LibraryLogger provides a logging interface that automatically adds context (module and class name) and offers helper methods for structured logging, such as API call logs. Attributes: debug (bool): Class attribute to enable or disable debug logging, prints API requests & responses that return an error. shouldredact (bool): Class attribute whether to redact sensitive information from logs. verbose (bool): Class attribute to print all request & response content. Examples: Logging an API call: >>> LibraryLogger.log_api_call(logger, response) 2025-02-01 12:34:56 - DEBUG - my_library - ========API CALL======== API CALL to endpoint: /api/v1/resource Method: POST Request Headers: { REQUEST HEADERS } Request Body: { "key": "value" } Response Body: { RESPONSE BODY } Logging an API exception: >>> LibraryLogger.log_api_exception(logger, request_dict, exception) 2025-02-01 12:34:56 - DEBUG - pyvesync - Error in API CALL to endpoint: /api/v1/resource Response Status: 500 Exception: Method: POST Request Headers: { REQUEST HEADERS } Request Body: { "key": "value" } """ debug = False """Class attribute to enable or disable debug logging - prints request and response content for errors only.""" shouldredact = True """Class attribute to determine if sensitive information should be redacted.""" verbose = False """Class attribute to print all request & response content.""" @classmethod def redactor(cls, stringvalue: str) -> str: """Redact sensitive strings from debug output. This method searches for specific sensitive keys in the input string and replaces their values with '##_REDACTED_##'. The keys that are redacted include: - token - authorizeCode - password - email - tk - accountId - authKey - uuid - cid Args: stringvalue (str): The input string potentially containing sensitive information. Returns: str: The redacted string with sensitive information replaced by '##_REDACTED_##'. """ if cls.shouldredact: stringvalue = re.sub( ( r'(?i)' r'((?<=token":\s")|' r'(?<=password":\s")|' r'(?<=email":\s")|' r'(?<=tk":\s")|' r'(?<=accountId":\s")|' r'(?<=accountID":\s")|' r'(?<=authKey":\s")|' r'(?<=uuid":\s")|' r'(?<=cid":\s")|' r'(?<=token\s)|' r'(?<=authorizeCode\s)|' r'(?<=account_id\s))' r'[^"\s]+' ), '##_REDACTED_##', stringvalue, ) return stringvalue @staticmethod def is_json(data: str | bytes | None) -> bool: """Check if the data is JSON formatted.""" if data is None: return False if isinstance(data, str): data = data.encode('utf-8') try: orjson.loads(data) except orjson.JSONDecodeError: return False return True @classmethod def api_printer(cls, api: Mapping | bytes | str | None) -> str | None: """Print the API dictionary in a readable format.""" if api is None or len(api) == 0: return None try: if isinstance(api, bytes): api_dict = orjson.loads(api) elif isinstance(api, str): api_dict = orjson.loads(api.encode('utf-8')) elif isinstance(api, (dict, CIMultiDictProxy)): api_dict = dict(api) else: return None dump = orjson.dumps( dict(api_dict), option=orjson.OPT_INDENT_2 | orjson.OPT_NON_STR_KEYS ) return cls.redactor(dump.decode('utf-8')) except (orjson.JSONDecodeError, orjson.JSONEncodeError): if isinstance(api, bytes): return api.decode('utf-8') return str(api) @staticmethod def set_log_level(level: str | int = logging.WARNING) -> None: """Set log level for logger to INFO.""" # Test default log levels if int or str logging.getLogger().setLevel(level) @staticmethod def configure_logger( level: str | int = logging.INFO, file_name: str | Path | None = None, std_out: bool = True, ) -> None: """Configure pyvesync library logger with a specific log level. Args: level (str | int): The log level to set the logger to, can be in form of enum `logging.DEBUG` or string `DEBUG`. file_name (str | None): The name of the file to log to. If None, logs will only be printed to the console. std_out (bool): If True, logs will be printed to standard output. Note: This method configures the pyvesync base logger and prevents propagation of log messages to the root logger to avoid duplicate messages. """ if level in (logging.DEBUG, 'DEBUG'): LibraryLogger.debug = True root_logger = logging.getLogger() if root_logger.handlers: for handler in root_logger.handlers: root_logger.removeHandler(handler) formatter = logging.Formatter( fmt='%(asctime)s - %(levelname)s - %(name)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S', ) if std_out is True: str_handler = logging.StreamHandler() str_handler.setFormatter(formatter) root_logger.addHandler(str_handler) if isinstance(file_name, str): file_name_path = Path(file_name).resolve() elif isinstance(file_name, Path): file_name_path = file_name.resolve() else: file_name_path = None if file_name_path: file_handler = logging.FileHandler(file_name_path) file_handler.setFormatter(formatter) root_logger.addHandler(file_handler) for log_name, logger in root_logger.manager.loggerDict.items(): if isinstance(logger, logging.Logger) and log_name.startswith('pyvesync'): logger.setLevel(level) @classmethod def log_mashumaro_response_error( cls, logger: logging.Logger, method_name: str, resp_dict: dict, exc: MissingField | InvalidFieldValue | UnserializableField, device: VeSyncBaseDevice | None = None, ) -> None: """Log an error parsing API response. Use this log message to indicate that the API response is not in the expected format. Args: logger (logging.Logger): module logger method_name (str): device name resp_dict (dict): API response dictionary exc (MissingField | InvalidFieldValue | UnserializableField): mashumaro exception caught device (VeSyncBaseDevice | None): device if a device method was called """ if device is not None: msg = ( f'Error parsing {device.product_type} {device.device_name} {method_name} ' f'response with data model {exc.holder_class_name}' ) else: msg = ( f'Error parsing {method_name} response with ' f'data model {exc.holder_class_name}' ) if isinstance(exc, MissingField): msg += f'Missing field: {exc.field_name} of type {exc.field_type_name}' elif isinstance(exc, InvalidFieldValue): msg += f'Invalid field value: {exc.field_name} of type {exc.field_type_name}' elif isinstance(exc, UnserializableField): msg += f'Unserializable field: {exc.field_name} of type {exc.field_type_name}' msg += ( '\n\n Please report this issue tohttps://github.com/webdjoe/pyvesync/issues' ) logger.warning(msg) if not cls.debug: return msg = '' if is_dataclass(exc.holder_class): holder = exc.holder_class field_tuple = fields(holder) resp_f = sorted(resp_dict.keys()) field_f = sorted([f.name for f in field_tuple]) dif = set(field_f) - set(resp_f) if dif: msg += '\n\n-------------------------------------' msg += '\n Expected Fields:' msg += f'\n({", ".join(field_f)})' msg += '\n Response Fields:' msg += f'\n({", ".join(resp_f)})' msg += '\n Missing Fields:' msg += f'\n({", ".join(dif)})' msg += '\n\n Full Response:' msg += f'\n{orjson.dumps(resp_dict, option=orjson.OPT_INDENT_2).decode("utf-8")}' if cls.verbose: msg += '\n\n---------------------------------' msg += '\n\n Exception:' msg += f'\n{exc.__traceback__}' logger.debug(msg) @classmethod def log_vs_api_response_error( cls, logger: logging.Logger, method_name: str, msg: str | None = None, ) -> None: """Log an error parsing API response. Use this log message to indicate that the API response is not in the expected format. Args: logger (logging.Logger): module logger method_name (str): device name msg (str | None, optional): optional description of error """ logger.debug( '%s API returned an unexpected response format: %s', method_name, msg if msg is not None else '', ) @classmethod def log_device_api_response_error( cls, logger: logging.Logger, device_name: str, device_type: str, method: str, msg: str | None = None, ) -> None: """Log an error parsing API response. Use this log message to indicate that the API response is not in the expected format. Args: logger (logging.Logger): module logger device_name (str): device name device_type (str): device type method (str): method that caused the error msg (str | None, optional): optional description of error """ logger.debug( '%s for %s API returned an unexpected response format in %s: %s', device_name, device_type, method, msg if msg is not None else '', ) @classmethod def log_device_return_code( cls, logger: logging.Logger, method: str, device_name: str, device_type: str, code: int, message: str = '', ) -> None: """Log return code from device API call. When API responds with JSON, if the code key is not 0, it indicates an error has occured. Args: logger (logging.Logger): module logger instance method (str): method that caused the error device_name (str): device name device_type (str): device type code (int): api response code message (str): api response message """ try: code_str = str(code) except (TypeError, ValueError): code_str = 'UNKNOWN' logger.debug( '%s for %s API from %s returned code: %s, message: %s', device_name, device_type, method, code_str, message, ) @classmethod def log_api_call( cls, logger: logging.Logger, response: ClientResponse, response_body: bytes | None = None, request_body: str | dict | None = None, ) -> None: """Log API calls in debug mode. Logs an API call with a specific format that includes the endpoint, JSON-formatted headers, request body (if any) and response body. Args: logger (logging.Logger): The logger instance to use. response (aiohttp.ClientResponse): Requests response object from the API call. response_body (bytes, optional): The response body to log. request_body (dict | str, optional): The request body to log. Notes: This is a method used for the logging of API calls when the debug flag is enabled. The method logs the endpoint, method, request headers, request body (if any), response headers, and response body (if any). """ if cls.verbose is False: return # Build the log message parts. parts = ['========API CALL========'] endpoint = response.url.path parts.append(f'API CALL to endpoint: {endpoint}') parts.append(f'Response Status: {response.status}') parts.append(f'Method: {response.method}') request_headers = cls.api_printer(response.request_info.headers) if request_headers: parts.append(f'Request Headers: {os.linesep} {request_headers}') if request_body is not None: request_body = cls.api_printer(request_body) parts.append(f'Request Body: {os.linesep} {request_body}') response_headers = cls.api_printer(response.headers) if response_headers: parts.append(f'Response Headers: {os.linesep} {response_headers}') if cls.is_json(response_body): response_str = cls.api_printer(response_body) parts.append(f'Response Body: {os.linesep} {response_str}') elif isinstance(response_body, bytes): response_str = response_body.decode('utf-8') parts.append(f'Response Body: {os.linesep} {response_str}') full_message = os.linesep.join(parts) logger.debug(full_message) @classmethod def log_api_status_error( cls, logger: logging.Logger, *, request_body: dict | None, response: ClientResponse, response_bytes: bytes | None, ) -> None: """Log API exceptions in debug mode. Logs an API call with a specific format that includes the endpoint, JSON-formatted headers, request body (if any) and response body. Args: logger (logging.Logger): The logger instance to use. request_body (dict | None): KW only, The request body to log. response (aiohttp.ClientResponse): KW only, dictionary containing the request information. response_bytes (bytes | None): KW only, The response body to log. """ if cls.debug is False: return # Build the log message parts. parts = [f'Error in API CALL to endpoint: {response.url.path}'] parts.append(f'Response Status: {response.status}') req_headers = cls.api_printer(response.request_info.headers) if req_headers is not None: parts.append(f'Request Headers: {os.linesep} {req_headers}') req_body = cls.api_printer(request_body) if req_body is not None: parts.append(f'Request Body: {os.linesep} {req_body}') resp_headers = cls.api_printer(response.headers) if resp_headers is not None: parts.append(f'Response Headers: {os.linesep} {resp_headers}') resp_body = cls.api_printer(response_bytes) if resp_body is not None: parts.append(f'Request Body: {os.linesep} {request_body}') full_message = os.linesep.join(parts) logger.debug(full_message) @classmethod def log_api_exception( cls, logger: logging.Logger, *, exception: ClientResponseError, request_body: dict | None, ) -> None: """Log API exceptions in debug mode. Logs an API call with a specific format that includes the endpoint, JSON-formatted headers, request body (if any) and response body. Args: logger (logging.Logger): The logger instance to use. exception (ClientResponseError): KW only, The request body to log. request_body (dict | None): KW only, The request body. """ if cls.debug is False: return # Build the log message parts. parts = [f'Error in API CALL to endpoint: {exception.request_info.url.path}'] parts.append(f'Exception Raised: {exception}') req_headers = cls.api_printer(exception.request_info.headers) if req_headers is not None: parts.append(f'Request Headers: {os.linesep} {req_headers}') req_body = cls.api_printer(request_body) if req_body is not None: parts.append(f'Request Body: {os.linesep} {req_body}') full_message = os.linesep.join(parts) logger.debug(full_message) webdjoe-pyvesync-eb8cecb/src/pyvesync/vesync.py000066400000000000000000000544051507433633000222100ustar00rootroot00000000000000"""VeSync API Device Libary.""" from __future__ import annotations import asyncio import logging from dataclasses import MISSING, fields from pathlib import Path from typing import Self from aiohttp import ClientSession from aiohttp.client_exceptions import ClientResponseError from mashumaro.mixins.orjson import DataClassORJSONMixin from pyvesync.auth import VeSyncAuth from pyvesync.const import ( DEFAULT_REGION, DEFAULT_TZ, MAX_API_REAUTH_RETRIES, REGION_API_MAP, STATUS_OK, ) from pyvesync.device_container import DeviceContainer, DeviceContainerInstance from pyvesync.models.vesync_models import ( FirmwareDeviceItemModel, RequestDeviceListModel, RequestFirmwareModel, ResponseDeviceListModel, ResponseFirmwareModel, ) from pyvesync.utils.errors import ( ErrorCodes, ErrorTypes, VeSyncAPIResponseError, VeSyncAPIStatusCodeError, VeSyncError, VeSyncServerError, VeSyncTokenError, raise_api_errors, ) from pyvesync.utils.helpers import Helpers from pyvesync.utils.logs import LibraryLogger logger = logging.getLogger(__name__) class VeSync: # pylint: disable=function-redefined """VeSync Manager Class.""" __slots__ = ( '__weakref__', '_api_attempts', '_auth', '_close_session', '_debug', '_device_container', '_redact', '_verbose', 'enabled', 'in_process', 'language', 'session', 'time_zone', ) def __init__( # noqa: PLR0913 self, username: str, password: str, country_code: str = DEFAULT_REGION, session: ClientSession | None = None, time_zone: str = DEFAULT_TZ, debug: bool = False, redact: bool = True, ) -> None: """Initialize VeSync Manager. This class is used as the manager for all VeSync objects, all methods and API calls are performed from this class. Time zone, debug and redact are optional. Time zone must be a string of an IANA time zone format. Once class is instantiated, call `await manager.login()` to log in to VeSync servers, which returns `True` if successful. Once logged in, call `await manager.get_devices()` to retrieve devices. Then `await `manager.update()` to update all devices or `await manager.devices[0].update()` to update a single device. Args: username (str): VeSync account username (usually email address) password (str): VeSync account password country_code (str): VeSync account country in ISO 3166 Alpha-2 format. By default, the account region is detected automatically at the login step If your account country is different from the default `US`, a second login attempt may be necessary - in this case you should specify the country directly to speed up the login process. session (ClientSession): aiohttp client session for API calls, by default None time_zone (str): Time zone for device from IANA database, by default DEFAULT_TZ. This is automatically set to the time zone of the VeSync account during login. debug (bool): Enable debug logging, by default False. redact (bool): Enable redaction of sensitive information, by default True. Attributes: session (ClientSession): Client session for API calls devices (DeviceContainer): Container for all VeSync devices, has functionality of a mutable set. See [`DeviceContainer`][pyvesync.device_container.DeviceContainer] for more information auth (VeSyncAuth): Authentication manager time_zone (str): Time zone for VeSync account pulled from API enabled (bool): True if logged in to VeSync, False if not Note: This class is a context manager, use `async with VeSync() as manager:` to manage the session context. The session will be closed when exiting if no session is passed in. The `manager.devices` attribute is a DeviceContainer object that contains all VeSync devices. The `manager.devices` object has the functionality of a set, and can be iterated over to access devices. See :obj:`DeviceContainer` for more information. If using a context manager is not convenient, `manager.__aenter__()` and `manager.__aexit__()` can be called directly. Either username/password or token/account_id must be provided for authentication. See Also: :obj:`DeviceContainer` Container object to store VeSync devices :obj:`DeviceState` Object to store device state information """ self.session = session self._api_attempts = 0 self._close_session = False self._debug = debug self.redact = redact self._verbose: bool = False self.time_zone: str = time_zone self.language: str = 'en' self.enabled = False self.in_process = False self._device_container: DeviceContainer = DeviceContainerInstance # Initialize authentication manager self._auth = VeSyncAuth( manager=self, username=username, password=password, country_code=country_code, ) @property def devices(self) -> DeviceContainer: """Return VeSync device container. See Also: The pyvesync.device_container.DeviceContainer object for methods and properties. """ return self._device_container @property def auth(self) -> VeSyncAuth: """Return VeSync authentication manager.""" return self._auth @property def country_code(self) -> str: """Return country code.""" return self._auth.country_code @property def current_region(self) -> str: """Return current region.""" return self._auth.current_region @property def token(self) -> str: """Return authentication token. Returns: str: Authentication token. Raises: AttributeError: If token is not set. """ return self._auth.token @property def account_id(self) -> str: """Return account ID. Returns: str: Account ID. Raises: AttributeError: If account ID is not set. """ return self._auth.account_id @property def debug(self) -> bool: """Return debug flag.""" return self._debug @debug.setter def debug(self, new_flag: bool) -> None: """Set debug flag.""" if new_flag: LibraryLogger.debug = True LibraryLogger.configure_logger(logging.DEBUG) else: LibraryLogger.debug = False LibraryLogger.configure_logger(logging.WARNING) self._debug = new_flag @property def verbose(self) -> bool: """Enable verbose logging.""" return LibraryLogger.verbose @verbose.setter def verbose(self, new_flag: bool) -> None: """Set verbose logging.""" if new_flag: LibraryLogger.verbose = True LibraryLogger.configure_logger(logging.DEBUG) else: LibraryLogger.verbose = False self._verbose = new_flag @property def redact(self) -> bool: """Return debug flag.""" return self._redact @redact.setter def redact(self, new_flag: bool) -> None: """Set debug flag.""" if new_flag: LibraryLogger.shouldredact = True elif new_flag is False: LibraryLogger.shouldredact = False self._redact = new_flag def output_credentials(self) -> str | None: """Output current authentication credentials as a JSON string.""" return self.auth.output_credentials() async def save_credentials(self, filename: str | Path | None) -> None: """Save authentication credentials to a file. Args: filename (str | Path | None): The name of the file to save credentials to. If None, no action is taken. """ if filename is not None: await self.auth.save_credentials_to_file(filename) async def load_credentials_from_file( self, filename: str | Path | None = None ) -> bool: """Load authentication credentials from a file. Args: filename (str | Path | None): The name of the file to load credentials from. If None, no action is taken. Returns: bool: True if credentials were loaded successfully, False otherwise. """ return await self.auth.load_credentials_from_file(filename) def set_credentials( self, token: str, account_id: str, country_code: str, region: str ) -> None: """Set authentication credentials. Args: token (str): Authentication token. account_id (str): Account ID. country_code (str): Country code in ISO 3166 Alpha-2 format. region (str): Current region code. """ self._auth.set_credentials(token, account_id, country_code, region) def log_to_file(self, filename: str | Path, std_out: bool = True) -> None: """Log to file and enable debug logging. Args: filename (str | Path): The name of the file to log to. std_out (bool): If False, logs will not print to std out. """ self.debug = True LibraryLogger.configure_logger(logging.DEBUG, file_name=filename, std_out=std_out) logger.debug('Logging to file: %s', filename) def process_devices(self, dev_list_resp: ResponseDeviceListModel) -> bool: """Instantiate Device Objects. Internal method run by `get_devices()` to instantiate device objects. """ current_device_count = len(self._device_container) self._device_container.remove_stale_devices(dev_list_resp) new_device_count = len(self._device_container) if new_device_count != current_device_count: logger.debug( 'Removed %s devices', str(current_device_count - new_device_count) ) current_device_count = new_device_count self._device_container.add_new_devices(dev_list_resp, self) new_device_count = len(self._device_container) if new_device_count != current_device_count: logger.debug('Added %s devices', str(new_device_count - current_device_count)) return True async def get_devices(self) -> bool: """Return tuple listing outlets, switches, and fans of devices. This is also called by `VeSync.update()` Raises: VeSyncAPIResponseError: If API response is invalid. VeSyncServerError: If server returns an error. """ self.in_process = True proc_return = False if not self.auth.is_authenticated or ( not self.auth.token or not self.auth.account_id ): logger.debug("Not logged in to VeSync, can't get devices") return False request_model = RequestDeviceListModel( token=self.auth.token, accountID=self.auth.account_id, timeZone=self.time_zone ) response_dict, _ = await self.async_call_api( '/cloud/v1/deviceManaged/devices', 'post', headers=Helpers.req_header_bypass(), json_object=request_model.to_dict(), ) if response_dict is None: raise VeSyncAPIResponseError( 'Error receiving response to device list request' ) response = ResponseDeviceListModel.from_dict(response_dict) if response.code == 0: proc_return = self.process_devices(response) else: error_info = ErrorCodes.get_error_info(response.code) resp_message = response.msg info_msg = f'{error_info.message} ({resp_message})' if error_info.error_type == ErrorTypes.SERVER_ERROR: raise VeSyncServerError(info_msg) raise VeSyncAPIResponseError( 'Error receiving response to device list request' ) self.in_process = False return proc_return async def login(self) -> bool: # pylint: disable=W9006 # pylint mult docstring raises """Log into VeSync server. Username and password are provided when class is instantiated. Returns: True if login successful, False otherwise Raises: VeSyncLoginError: If login fails, for example due to invalid username or password. VeSyncAPIResponseError: If API response is invalid. VeSyncServerError: If server returns an error. """ self.enabled = False success = await self._auth.login() if success: self.enabled = True return success async def update(self) -> None: """Fetch updated information about devices and new device list. Pulls devices list from VeSync and instantiates any new devices. Devices are stored in the instance attributes `outlets`, `switches`, `fans`, and `bulbs`. The `_device_list` attribute is a dictionary of these attributes. """ if not self.enabled: logger.error('Not logged in to VeSync') return await self.get_devices() await self.update_all_devices() async def update_all_devices(self) -> None: """Run `get_details()` for each device and update state.""" logger.debug('Start updating the device details one by one') if len(self._device_container) == 0: logger.error('No devices to update') return update_tasks: list[asyncio.Task] = [ asyncio.create_task(device.update()) for device in self._device_container ] done, _ = await asyncio.wait(update_tasks, return_when=asyncio.ALL_COMPLETED) for task in done: exc = task.exception() if exc is not None and isinstance(exc, VeSyncError): logger.error('Error updating device: %s', exc) async def __aenter__(self) -> Self: """Asynchronous context manager enter.""" return self async def __aexit__(self, *exec_info: object) -> None: """Asynchronous context manager exit.""" if self.session and self._close_session: logger.debug('Closing session, exiting context manager') await self.session.close() return logger.debug('Session not closed, exiting context manager') async def _reauthenticate(self) -> bool: """Re-authenticate using stored username and password. Returns: True if re-authentication successful, False otherwise """ self.enabled = False self._api_attempts += 1 if self._api_attempts >= MAX_API_REAUTH_RETRIES: logger.error('Max API re-authentication attempts reached') raise VeSyncTokenError success = await self.auth.reauthenticate() if success: self.enabled = True self._api_attempts = 0 return True return await self.auth.reauthenticate() async def async_call_api( self, api: str, method: str, json_object: dict | None | DataClassORJSONMixin = None, headers: dict | None = None, ) -> tuple[dict | None, int | None]: """Make API calls by passing endpoint, header and body. api argument is appended to `API_BASE_URL`. Raises VeSyncRateLimitError if API returns a rate limit error. Args: api (str): Endpoint to call with `API_BASE_URL`. method (str): HTTP method to use. json_object (dict | RequestBaseModel): JSON object to send in body. headers (dict): Headers to send with request. Returns: tuple[dict | None, int]: Response and status code. Attempts to parse response as JSON, if not possible returns None. Raises: VeSyncAPIStatusCodeError: If API returns an error status code. VeSyncRateLimitError: If API returns a rate limit error. VeSyncServerError: If API returns a server error. VeSyncTokenError: If API returns an authentication error. ClientResponseError: If API returns a client response error. Note: Future releases will require the `json_object` argument to be a dataclass, instead of dictionary. """ if self.session is None: self.session = ClientSession() self._close_session = True self._api_attempts += 1 response = None status_code = None if isinstance(json_object, DataClassORJSONMixin): req_dict = json_object.to_dict() elif isinstance(json_object, dict): req_dict = json_object else: req_dict = None try: async with self.session.request( method, url=self._api_base_url_for_current_region() + api, json=req_dict, headers=headers, raise_for_status=False, ) as response: status_code = response.status resp_bytes = await response.read() if status_code != STATUS_OK: LibraryLogger.log_api_status_error( logger, request_body=req_dict, response=response, response_bytes=resp_bytes, ) raise VeSyncAPIStatusCodeError(str(status_code)) LibraryLogger.log_api_call(logger, response, resp_bytes, req_dict) resp_dict = Helpers.try_json_loads(resp_bytes) if isinstance(resp_dict, dict): error_info = ErrorCodes.get_error_info(resp_dict.get('code')) if error_info.error_type == ErrorTypes.TOKEN_ERROR: if await self._reauthenticate(): self.enabled = True return await self.async_call_api( api, method, json_object, headers ) raise VeSyncTokenError if resp_dict.get('msg') is not None: error_info.message = f'{error_info.message} ({resp_dict["msg"]})' raise_api_errors(error_info) return resp_dict, status_code except ClientResponseError as e: LibraryLogger.log_api_exception(logger, exception=e, request_body=req_dict) raise def _api_base_url_for_current_region(self) -> str: """Retrieve the API base url for the current region. At this point, only two different URLs exist: One for `EU` region (for all EU countries), and one for all others (currently `US`, `CA`, `MX`, `JP` - also used as a fallback). If `API_BASE_URL` is set, it will take precedence over the determined URL. """ return REGION_API_MAP[self.current_region] def _update_fw_version(self, info_list: list[FirmwareDeviceItemModel]) -> bool: """Update device firmware versions from API response.""" if not info_list: logger.debug('No devices found in firmware response') return False update_dict = {} for device in info_list: if not device.firmUpdateInfos: if device.code != 0: logger.debug( 'Device %s has error code %s with message: %s', device.deviceName, device.code, device.msg, ) else: logger.debug( 'Device %s has no firmware updates available', device.deviceName ) continue for update_info in device.firmUpdateInfos: update_dict[device.deviceCid] = ( update_info.currentVersion, update_info.latestVersion, ) if update_info.isMainFw is True: break for device_obj in self._device_container: if device_obj.cid in update_dict: device_obj.latest_firm_version = update_dict[device_obj.cid][1] device_obj.current_firm_version = update_dict[device_obj.cid][0] return True async def check_firmware(self) -> bool: """Check for firmware updates for all devices. This method will check for firmware updates for all devices in the device container. It will call the `get_firmware_update()` method on each device and log the results. """ if len(self._device_container) == 0: logger.debug('No devices to check for firmware updates') return False body_fields = [ field.name for field in fields(RequestFirmwareModel) if field.default_factory is MISSING and field.default is MISSING ] body = Helpers.get_class_attributes(self, body_fields) body['cidList'] = [device.cid for device in self._device_container] resp_dict, _ = await self.async_call_api( '/cloud/v2/deviceManaged/getFirmwareUpdateInfoList', 'post', json_object=RequestFirmwareModel(**body), ) if resp_dict is None: raise VeSyncAPIResponseError( 'Error receiving response to firmware update request' ) resp_model = ResponseFirmwareModel.from_dict(resp_dict) if resp_model.code != 0: error_info = ErrorCodes.get_error_info(resp_model.code) resp_message = resp_model.msg if resp_message is not None: error_info.message = f'{error_info.message} ({resp_message})' logger.debug('Error in firmware update response: %s', error_info.message) return False info_list = resp_model.result.cidFwInfoList return self._update_fw_version(info_list) webdjoe-pyvesync-eb8cecb/src/pyvesync/vesynchome.py000066400000000000000000000132001507433633000230450ustar00rootroot00000000000000"""This is a WIP, not implemented yet.""" from __future__ import annotations import asyncio import logging from dataclasses import fields from typing import TYPE_CHECKING from pyvesync.models.base_models import DefaultValues from pyvesync.models.home_models import ( IntResponseHomeListModel, IntResponseHomeResultModel, RequestHomeModel, ResponseHomeModel, ) from pyvesync.utils.errors import ErrorCodes, VeSyncAPIResponseError from pyvesync.utils.helpers import Helpers if TYPE_CHECKING: from pyvesync.base_devices import VeSyncBaseDevice from pyvesync.models.base_models import RequestBaseModel from pyvesync.vesync import VeSync _LOGGER = logging.getLogger(__name__) class VeSyncHome: """Class to handle home-related operations in VeSync.""" def __init__(self, home_id: int, name: str, nickname: str | None = None) -> None: """Initialize the VeSyncHome instance.""" self.home_id: int = home_id self.name: str = name self.nickname: str | None = nickname self.rooms: list[VeSyncRoom] = [] @property def devices(self) -> list[VeSyncBaseDevice]: """Return a list of all devices in the home.""" devices = [] for room in self.rooms: devices.extend(room.devices) return devices async def update_devices(self, devices: list[VeSyncBaseDevice]) -> None: """Update the devices in the home.""" update_tasks = [device.update() for device in devices] for update_coro in asyncio.as_completed(update_tasks): try: await update_coro except Exception as e: # pylint: disable=broad-except _LOGGER.debug('Error updating device %s', e) @staticmethod def _build_request_model( manager: VeSync, request_model: type[RequestBaseModel] ) -> RequestBaseModel: """Build the request model for home data.""" req_fields = [field.name for field in fields(request_model) if field.init] body = Helpers.get_class_attributes(DefaultValues, req_fields) body.update(Helpers.get_class_attributes(manager, req_fields)) return request_model(**body) @classmethod def _process_home_list( cls, home_list: list[IntResponseHomeListModel] ) -> list[VeSyncHome]: """Process the home list and return a list of VeSyncHome instances.""" homes = [] for home in home_list: if not isinstance(home, IntResponseHomeListModel): msg = ( 'Invalid home list item type.' f'Expected IntResponseHomeListModel, got {home}' ) raise VeSyncAPIResponseError(msg) homes.append(VeSyncHome(home.homeId, home.homeName, home.nickname)) return homes @classmethod async def build_homes(cls, manager: VeSync) -> bool: """Get home information. This method retrieves the home list from the VeSync API and populates the VeSyncHome instances in the manager. The objects are stored in `manager.homes` attribute as a list. Args: manager (VeSync): The VeSync instance to use for the API call. Returns: bool: True if the home list was successfully retrieved and populated. Raises: VeSyncAPIResponseError: If the API response contains an error or if the home list is empty. """ body = cls._build_request_model(manager, RequestHomeModel) response, _ = await manager.async_call_api( '/cloud/v1/homeManaged/getHomeList', method='post', json_object=body ) if response is None: raise VeSyncAPIResponseError( 'Response is None, enable debugging to see more information.' ) resp_model = ResponseHomeModel.from_dict(response) if resp_model.code != 0: error = ErrorCodes.get_error_info(resp_model.code) if resp_model.msg is not None: error.message = f'{resp_model.msg} ({error.message})' msg = f'Failed to get home list with error: {error.to_json()}' raise VeSyncAPIResponseError(msg) result = resp_model.result if not isinstance(result, IntResponseHomeResultModel): msg = ( 'Error in home list API response.' f'Expected IntResponseHomeResultModel, got {result}' ) raise VeSyncAPIResponseError(msg) home_list = result.homeList if not home_list: raise VeSyncAPIResponseError('No homes found in the response.') for home in home_list: if not isinstance(home, IntResponseHomeListModel): msg = ( 'Invalid home list item type.' f'Expected IntResponseHomeListModel, got {home}' ) raise VeSyncAPIResponseError(msg) return True class VeSyncRoom: """Class to handle room-related operations in VeSync.""" def __init__(self, room_id: str, name: str) -> None: """Initialize the VeSyncRoom instance.""" self.room_id: str = room_id self.name: str = name self.devices: list[VeSyncBaseDevice] = [] @staticmethod def _build_request_model( manager: VeSync, request_model: type[RequestBaseModel] ) -> RequestBaseModel: """Build the request model for room data.""" req_fields = [field.name for field in fields(request_model) if field.init] body = Helpers.get_class_attributes(DefaultValues, req_fields) body.update(Helpers.get_class_attributes(manager, req_fields)) return request_model(**body) webdjoe-pyvesync-eb8cecb/src/tests/000077500000000000000000000000001507433633000176015ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/src/tests/README.md000066400000000000000000000306021507433633000210610ustar00rootroot00000000000000# The pyvesync testing library This is the testing suite for the pyvesync library. Each device that is added must include tests. This helps to maintain the consistency of the API as new devices are added and the backend is refactored. I've built a relatively simple framework to ease the burden of writing tests. There are some old tests that I had previously written that I've kept as I build the new framework but these tests were not comprehensive or portable. The files that begin with `test_x_` are these previous tests and can safely be ignored. The tests primarily run each API call for each device and record the request details in YAML files in the `tests\API` directory. These files are then used to verify the request when the test is run again. 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. 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 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 The first test run verifies that all of the devices defined in each pyvesync module have a corresponding response in each `call_json_DEVICE` module. This verifies that when a new device is added, a corresponding response is added to be tested. 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 and the values specified in the `Defaults` and `DeviceDefaults` classes. ## File Structure ### Device Responses and Details The call_json files contain all of the response data for each device type. The following call_json files are included in the test directory: - `call_json.py` - general API responses, including `login()` and `get_devices()`. The device list from the `get_devices()` can be used to create the device list response for all devices. - `call_json_outlets.py` - API responses for the outlets - `call_json_switches.py` - API responses for the switches - `call_json_fans.py` - API responses for the fans - `call_json_bulbs.py` - API responses for the bulbs #### call_json.py The `call_json.py` file contains the functions to build the `get_devices()` response containing the device list and each item on the device list. The `DeviceList` class contains the `device_list_response(devices_types=None, _types=None)` method which returns the full device list response based on the defined device types (model number(s)) or types (outlets, fans, etc.). The `device_list_item(model)` builds the individual device list item that is used to instantiate the device object. The default values for device configuration values are pulled from the `Defaults` class in the `utils.py` module for consistency. #### 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. 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 copy import deepcopy device_types = ['dev1', 'dev2'] # 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. device_responses['dev2'].default_factory = lambda: ({'new_code': 0, 'msg': 'success', {'payload': {}}}, 200) ``` 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 METHOD_RESPONSES = {k: deepcopy(FunctionResponses) for k in BULBS} def valceno_set_status_response(kwargs=None): default_resp = { "traceId": Defaults.trace_id, "code": 0, "msg": "request success", "result": { "traceId": Defaults.trace_id, "code": 0, "result": { "enabled": "on", "colorMode": "hsv", "brightness": Defaults.brightness, "colorTemp": Defaults.color_temp, "hue": Defaults.color.hsv.hue*27.7778, "saturation": Defaults.color.hsv.saturation*100, "value": Defaults.color.hsv.value } } } if kwargs is not None and isinstance(kwargs, dict): if kwargs.get('hue') is not None: default_resp['result']['result']['hue'] = kwargs['hue'] * 27.7778 if kwargs.get('saturation') is not None: default_resp['result']['result']['saturation'] = kwargs['saturation'] * 100 if kwargs.get('value') is not None: default_resp['result']['result']['value'] = kwargs['value'] return default_resp, 200 XYD0001_RESP = { 'set_brightness': valceno_set_status_response, 'set_color_temp': valceno_set_status_response, 'set_hsv': valceno_set_status_response, 'set_rgb': valceno_set_status_response, } METHOD_RESPONSES['XYD0001'].update(XYD0001_RESP) ``` ### **`api`** directory with `YAML` files 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 turn_off: headers: accept-language: en accountId: sample_id appVersion: 2.8.6 content-type: application/json tk: sample_tk tz: America/New_York json_object: acceptLanguage: en accountID: sample_id status: 'off' switchNo: 3 timeZone: America/New_York token: sample_tk uuid: ESO15-TB-UUID method: put url: /outdoorsocket15a/v1/device/devicestatus ``` ### **`utils.py`** - utility functions and default value factory for tests. The `utils.py` file contains several helper functions and classes: ### 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. ```python from utils import Defaults # Default Class variables token = Defaults.token account_id = Defaults.account_id trace_id = Defaults.trace_id active_time = Defaults.active_time # The default Color dataclass contains the attributes red, green, blue, hue saturation & value. Conversion is automatically done regardless of the input color model. This is to normalize any API calls that involve changing color color = Color(red=50, green=100, blue=255) brightness = Defaults.brightness color_temp = Defaults.color_temp # Default values that use methods device_name = Defaults.name(dev_type="ESL100") # returns 'ESL100-NAME' device_cid = Defaults.cid(dev_type="ESL100") # returns 'ESL100-CID' 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, allowing a return value to be set.. ```python from utils import TestBase, FunctionResponses class TestDevice(TestBase): def test_details(self): vesync_instance = self.manager mock_api_object = self.mock_api # patch('pyvesync.helpers.call_api', autopspec=True) mock_api_object.return_value = FunctionResponses['default'] caplog = self.caplog assert vesync_instance.enabled is True ``` ## Test Structure Each module in the pyvesync library has an associated testing module, for example, `vesyncswitches` and `test_switches`. Most testing modules have one class, except for the `test_fans` module, which has separate classes for humidifiers and air purifiers. The class inherits from the `TestBase` class in `utils.py` and is parametrized by `pytest_generate_tests` based on the method. The parameters are defined by the class attributes. The `base_methods` and `device_methods` class attributes define the method and arguments in a list of lists with the first item, the method name and the second optional item, the method kwargs. The `base_methods` class attribute defines methods that are common to all devices. The `device_methods` class attribute defines methods that are specific to the device type. This is an examples of the class definition: ```python from utils import TestBase class TestBulbs(TestBase): device = 'bulbs' bulbs = call_json_bulbs.BULBS base_methods = [['turn_on'], ['turn_off'], ['set_brightness', {'brightness': 50}]] device_methods = { 'ESL100CW': [['set_color_temp', {'color_temp': 50}]] } ``` 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. webdjoe-pyvesync-eb8cecb/src/tests/aiohttp_mocker.py000066400000000000000000000143651507433633000231740ustar00rootroot00000000000000import re from http import HTTPStatus from types import TracebackType from collections import namedtuple from unittest import mock from urllib.parse import parse_qs from yarl import URL from orjson import dumps, loads from aiohttp import ( ClientConnectionError, ClientResponseError, StreamReader ) from multidict import CIMultiDict RETYPE = type(re.compile("")) REQUEST_INFO = namedtuple("REQUEST_INFO", ["url", "method", "headers", "real_url"]) def mock_stream(data) -> StreamReader: """Mock a stream with data.""" protocol = mock.Mock(_reading_paused=False) stream = StreamReader(protocol, limit=2**16) stream.feed_data(data) stream.feed_eof() return stream class AiohttpMockSession: """MockAiohttpsession.""" def __init__(self, url, method, response, status, headers=None): self.url = URL(url) self.method = method self.response_bytes = response self.status = status self._headers = CIMultiDict(headers or {}) @property def headers(self): """Return content_type.""" return self._headers async def __aenter__(self): return AiohttpClientMockResponse( method=self.method, url=self.url, status=self.status, response=self.response_bytes ) async def __aexit__(self, *args): pass class AiohttpClientMockResponse: """Mock Aiohttp client response. Adapted from home-assistant/core/tests/test_util/aiohttp.py """ def __init__( self, method, url: URL, status=HTTPStatus.OK, response=None, json=None, text=None, cookies=None, exc=None, headers=None, side_effect=None, closing=None, ) -> None: """Initialize a fake response. Args: method: str: HTTP method url: URL: URL of the response status: HTTPStatus: HTTP status code response: bytes: Response content json: dict: JSON response content text: str: Text response content cookies: dict: Cookies to set exc: Exception: Exception to raise headers: dict: Headers to set side_effect: Exception: Exception to raise when read closing: bool: Simulate closing connection """ if json is not None: text = dumps(json) if isinstance(text, str): response = text.encode("utf-8") if response is None: response = b"" self.charset = "utf-8" self.method = method self._url = url self.status = status self._response = response self.exc = exc self.side_effect = side_effect self.closing = closing self._headers = CIMultiDict(headers or {}) self._cookies = {} if cookies: for name, data in cookies.items(): cookie = mock.MagicMock() cookie.value = data self._cookies[name] = cookie def match_request(self, method, url, params=None): """Test if response answers request.""" if method.lower() != self.method.lower(): return False # regular expression matching if isinstance(self._url, RETYPE): return self._url.search(str(url)) is not None if ( self._url.scheme != url.scheme or self._url.host != url.host or self._url.path != url.path ): return False # Ensure all query components in matcher are present in the request request_qs = parse_qs(url.query_string) matcher_qs = parse_qs(self._url.query_string) for key, vals in matcher_qs.items(): for val in vals: try: request_qs.get(key, []).remove(val) except ValueError: return False return True @property def request_info(self): """Return request info.""" return REQUEST_INFO( url=self.url, method=self.method, headers=self.headers, real_url=self.url) @property def headers(self): """Return content_type.""" return self._headers @property def cookies(self): """Return dict of cookies.""" return self._cookies @property def url(self): """Return yarl of URL.""" return self._url @property def content_type(self): """Return yarl of URL.""" return self._headers.get("content-type") @property def content(self): """Return content.""" return mock_stream(self.response) async def read(self): """Return mock response.""" return self.response async def text(self, encoding="utf-8", errors="strict"): """Return mock response as a string.""" return self.response.decode(encoding, errors=errors) async def json(self, encoding="utf-8", content_type=None, loads=loads): """Return mock response as a json.""" return loads(self.response.decode(encoding)) def release(self): """Mock release.""" def raise_for_status(self): """Raise error if status is 400 or higher.""" if self.status >= 400: request_info = mock.Mock(real_url="http://example.com") raise ClientResponseError( request_info=request_info, history=None, # type: ignore status=self.status, headers=self.headers, ) def close(self): """Mock close.""" async def wait_for_close(self): """Wait until all requests are done. Do nothing as we are mocking. """ @property def response(self): """Property method to expose the response to other read methods.""" if self.closing: raise ClientConnectionError("Connection closed") return self._response async def __aenter__(self): """Enter the context manager.""" return self async def __aexit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> None: """Exit the context manager.""" webdjoe-pyvesync-eb8cecb/src/tests/api/000077500000000000000000000000001507433633000203525ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/src/tests/api/vesync/000077500000000000000000000000001507433633000216615ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/src/tests/api/vesync/VeSync.yaml000066400000000000000000000013171507433633000237560ustar00rootroot00000000000000get_devices: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 method: devices pageNo: 1 pageSize: 100 phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID method: post url: /cloud/v1/deviceManaged/devices login: json_object: acceptLanguage: en appVersion: 5.6.60 devToken: '' email: EMAIL method: login password: PASSWORD phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York traceId: TRACE_ID userType: '1' method: post url: /cloud/v1/user/login webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncbulb/000077500000000000000000000000001507433633000225265ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncbulb/ESL100.yaml000066400000000000000000000045321507433633000242620ustar00rootroot00000000000000set_brightness: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 brightNess: 50 cid: ESL100-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESL100-CID method: smartBulbBrightnessCtl phoneBrand: pyvesync phoneOS: Android status: 'on' timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESL100-UUID method: post url: /cloud/v1/deviceManaged/smartBulbBrightnessCtl turn_off: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESL100-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESL100-CID method: smartBulbPowerSwitchCtl phoneBrand: pyvesync phoneOS: Android status: 'off' timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESL100-UUID method: post url: /cloud/v1/deviceManaged/smartBulbPowerSwitchCtl turn_on: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESL100-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESL100-CID method: smartBulbPowerSwitchCtl phoneBrand: pyvesync phoneOS: Android status: 'on' timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESL100-UUID method: post url: /cloud/v1/deviceManaged/smartBulbPowerSwitchCtl update: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESL100-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESL100-CID method: deviceDetail phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESL100-UUID method: post url: /cloud/v1/deviceManaged/deviceDetail webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncbulb/ESL100CW.yaml000066400000000000000000000060611507433633000245130ustar00rootroot00000000000000set_brightness: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESL100CW-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESL100CW-CID jsonCmd: light: action: 'on' brightness: 50 colorTempe: 100 method: bypass phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESL100CW-UUID method: post url: /cloud/v1/deviceManaged/bypass set_color_temp: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESL100CW-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESL100CW-CID jsonCmd: light: action: 'on' brightness: 100 colorTempe: 50 method: bypass phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESL100CW-UUID method: post url: /cloud/v1/deviceManaged/bypass turn_off: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESL100CW-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESL100CW-CID jsonCmd: light: action: 'off' method: bypass phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESL100CW-UUID method: post url: /cloud/v1/deviceManaged/bypass turn_on: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESL100CW-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESL100CW-CID jsonCmd: light: action: 'on' method: bypass phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESL100CW-UUID method: post url: /cloud/v1/deviceManaged/bypass update: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESL100CW-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESL100CW-CID jsonCmd: getLightStatus: get method: bypass phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESL100CW-UUID method: post url: /cloud/v1/deviceManaged/bypass webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncbulb/ESL100MC.yaml000066400000000000000000000100571507433633000245010ustar00rootroot00000000000000set_brightness: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESL100MC-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESL100MC-CID method: bypassV2 payload: data: action: 'on' blue: 0 brightness: 50 colorMode: white green: 0 red: 0 speed: 0 method: setLightStatus source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_rgb: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESL100MC-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESL100MC-CID method: bypassV2 payload: data: action: 'on' blue: 255 brightness: 100 colorMode: color green: 200 red: 50 speed: 0 method: setLightStatus source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_white_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESL100MC-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESL100MC-CID method: bypassV2 payload: data: action: 'on' blue: 0 brightness: 100 colorMode: white green: 0 red: 0 speed: 0 method: setLightStatus source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: ESL100MC-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESL100MC-CID method: bypassV2 payload: data: enabled: false id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: ESL100MC-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESL100MC-CID method: bypassV2 payload: data: enabled: true id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: ESL100MC-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESL100MC-CID method: bypassV2 payload: data: {} method: getLightStatus source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncbulb/XYD0001.yaml000066400000000000000000000106351507433633000243640ustar00rootroot00000000000000set_brightness: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: XYD0001-CID configModule: ConfigModule debugMode: false deviceRegion: US method: bypassV2 payload: data: brightness: 50 colorMode: '' colorTemp: '' force: 0 hue: '' saturation: '' value: '' method: setLightStatusV2 source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID method: post url: /cloud/v2/deviceManaged/bypassV2 set_color_temp: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: XYD0001-CID configModule: ConfigModule debugMode: false deviceRegion: US method: bypassV2 payload: data: brightness: '' colorMode: white colorTemp: 50 force: 1 hue: '' saturation: '' value: '' method: setLightStatusV2 source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID method: post url: /cloud/v2/deviceManaged/bypassV2 set_hsv: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: XYD0001-CID configModule: ConfigModule debugMode: false deviceRegion: US method: bypassV2 payload: data: brightness: '' colorMode: hsv colorTemp: '' force: 1 hue: 5555 saturation: 5000 value: 100 method: setLightStatusV2 source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID method: post url: /cloud/v2/deviceManaged/bypassV2 set_white_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: XYD0001-CID configModule: ConfigModule debugMode: false deviceRegion: US method: bypassV2 payload: data: brightness: '' colorMode: white colorTemp: '' force: 1 hue: '' saturation: '' value: '' method: setLightStatusV2 source: APP phoneBrand: pyvesync 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: 5.6.60 cid: XYD0001-CID configModule: ConfigModule debugMode: false deviceRegion: US method: bypassV2 payload: data: enabled: false id: 0 method: setSwitch source: APP phoneBrand: pyvesync 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: 5.6.60 cid: XYD0001-CID configModule: ConfigModule debugMode: false deviceRegion: US method: bypassV2 payload: data: enabled: true id: 0 method: setSwitch source: APP phoneBrand: pyvesync 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: 5.6.60 cid: XYD0001-CID configModule: ConfigModule debugMode: false deviceRegion: US method: bypassV2 payload: data: {} method: getLightStatusV2 source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID method: post url: /cloud/v2/deviceManaged/bypassV2 webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncfan/000077500000000000000000000000001507433633000223465ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncfan/LTF-F422S.yaml000066400000000000000000000074711507433633000244260ustar00rootroot00000000000000set_fan_speed: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LTF-F422S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LTF-F422S-CID method: bypassV2 payload: data: levelIdx: 0 levelType: wind manualSpeedLevel: 3 method: setLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LTF-F422S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LTF-F422S-CID method: bypassV2 payload: data: powerSwitch: 0 switchIdx: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_off_mute: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LTF-F422S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LTF-F422S-CID method: bypassV2 payload: data: muteSwitch: 0 method: setMuteSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_off_oscillation: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LTF-F422S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LTF-F422S-CID method: bypassV2 payload: data: oscillationSwitch: 0 method: setOscillationSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LTF-F422S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LTF-F422S-CID method: bypassV2 payload: data: powerSwitch: 1 switchIdx: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LTF-F422S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LTF-F422S-CID method: bypassV2 payload: data: {} method: getTowerFanStatus source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 webdjoe-pyvesync-eb8cecb/src/tests/api/vesynchumidifier/000077500000000000000000000000001507433633000237275ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/src/tests/api/vesynchumidifier/Classic200S.yaml000066400000000000000000000160061507433633000265440ustar00rootroot00000000000000set_auto_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Classic200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic200S-CID method: bypassV2 payload: data: mode: auto method: setHumidityMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_humidity: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Classic200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic200S-CID method: bypassV2 payload: data: target_humidity: 50 method: setTargetHumidity source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_manual_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Classic200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic200S-CID method: bypassV2 payload: data: mode: manual method: setHumidityMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_mist_level: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Classic200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic200S-CID method: bypassV2 payload: data: id: 0 level: 2 type: mist method: setVirtualLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: Classic200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic200S-CID method: bypassV2 payload: data: enabled: false id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_off_automatic_stop: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Classic200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic200S-CID method: bypassV2 payload: data: enabled: false method: setAutomaticStop source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_off_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Classic200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic200S-CID method: bypassV2 payload: data: enabled: false id: 0 method: setIndicatorLightSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: Classic200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic200S-CID method: bypassV2 payload: data: enabled: true id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_on_automatic_stop: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Classic200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic200S-CID method: bypassV2 payload: data: enabled: true method: setAutomaticStop source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_on_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Classic200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic200S-CID method: bypassV2 payload: data: enabled: true id: 0 method: setIndicatorLightSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: Classic200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic200S-CID method: bypassV2 payload: data: {} method: getHumidifierStatus source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 webdjoe-pyvesync-eb8cecb/src/tests/api/vesynchumidifier/Classic300S.yaml000066400000000000000000000157141507433633000265520ustar00rootroot00000000000000set_auto_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Classic300S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic300S-CID method: bypassV2 payload: data: mode: auto method: setHumidityMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_humidity: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Classic300S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic300S-CID method: bypassV2 payload: data: target_humidity: 50 method: setTargetHumidity source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_manual_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Classic300S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic300S-CID method: bypassV2 payload: data: mode: manual method: setHumidityMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_mist_level: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Classic300S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic300S-CID method: bypassV2 payload: data: id: 0 level: 2 type: mist method: setVirtualLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: Classic300S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic300S-CID method: bypassV2 payload: data: enabled: false id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_off_automatic_stop: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Classic300S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic300S-CID method: bypassV2 payload: data: enabled: false method: setAutomaticStop source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_off_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Classic300S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic300S-CID method: bypassV2 payload: data: state: false method: setDisplay source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: Classic300S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic300S-CID method: bypassV2 payload: data: enabled: true id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_on_automatic_stop: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Classic300S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic300S-CID method: bypassV2 payload: data: enabled: true method: setAutomaticStop source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_on_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Classic300S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic300S-CID method: bypassV2 payload: data: state: true method: setDisplay source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: Classic300S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Classic300S-CID method: bypassV2 payload: data: {} method: getHumidifierStatus source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 webdjoe-pyvesync-eb8cecb/src/tests/api/vesynchumidifier/Dual200S.yaml000066400000000000000000000156121507433633000260520ustar00rootroot00000000000000set_auto_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Dual200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Dual200S-CID method: bypassV2 payload: data: mode: auto method: setHumidityMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_humidity: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Dual200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Dual200S-CID method: bypassV2 payload: data: target_humidity: 50 method: setTargetHumidity source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_manual_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Dual200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Dual200S-CID method: bypassV2 payload: data: mode: manual method: setHumidityMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_mist_level: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Dual200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Dual200S-CID method: bypassV2 payload: data: id: 0 level: 2 type: mist method: setVirtualLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: Dual200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Dual200S-CID method: bypassV2 payload: data: enabled: false id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_off_automatic_stop: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Dual200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Dual200S-CID method: bypassV2 payload: data: enabled: false method: setAutomaticStop source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_off_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Dual200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Dual200S-CID method: bypassV2 payload: data: state: false method: setDisplay source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: Dual200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Dual200S-CID method: bypassV2 payload: data: enabled: true id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_on_automatic_stop: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Dual200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Dual200S-CID method: bypassV2 payload: data: enabled: true method: setAutomaticStop source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_on_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Dual200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Dual200S-CID method: bypassV2 payload: data: state: true method: setDisplay source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: Dual200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Dual200S-CID method: bypassV2 payload: data: {} method: getHumidifierStatus source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 webdjoe-pyvesync-eb8cecb/src/tests/api/vesynchumidifier/LEH-S601S.yaml000066400000000000000000000157331507433633000260060ustar00rootroot00000000000000set_auto_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LEH-S601S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LEH-S601S-CID method: bypassV2 payload: data: workMode: autoPro method: setHumidityMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_humidity: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LEH-S601S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LEH-S601S-CID method: bypassV2 payload: data: targetHumidity: 50 method: setTargetHumidity source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_manual_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LEH-S601S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LEH-S601S-CID method: bypassV2 payload: data: workMode: manual method: setHumidityMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_mist_level: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LEH-S601S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LEH-S601S-CID method: bypassV2 payload: data: levelIdx: 0 levelType: mist virtualLevel: 2 method: setVirtualLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LEH-S601S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LEH-S601S-CID method: bypassV2 payload: data: powerSwitch: 0 switchIdx: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_off_automatic_stop: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LEH-S601S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LEH-S601S-CID method: bypassV2 payload: data: autoStopSwitch: 0 method: setAutoStopSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_off_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LEH-S601S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LEH-S601S-CID method: bypassV2 payload: data: screenSwitch: 0 method: setDisplay source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LEH-S601S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LEH-S601S-CID method: bypassV2 payload: data: powerSwitch: 1 switchIdx: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_on_automatic_stop: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LEH-S601S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LEH-S601S-CID method: bypassV2 payload: data: autoStopSwitch: 1 method: setAutoStopSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_on_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LEH-S601S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LEH-S601S-CID method: bypassV2 payload: data: screenSwitch: 1 method: setDisplay source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LEH-S601S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LEH-S601S-CID method: bypassV2 payload: data: {} method: getHumidifierStatus source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 webdjoe-pyvesync-eb8cecb/src/tests/api/vesynchumidifier/LUH-A602S-WUS.yaml000066400000000000000000000157701507433633000264620ustar00rootroot00000000000000set_auto_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-A602S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-A602S-WUS-CID method: bypassV2 payload: data: mode: auto method: setHumidityMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_humidity: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-A602S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-A602S-WUS-CID method: bypassV2 payload: data: target_humidity: 50 method: setTargetHumidity source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_manual_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-A602S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-A602S-WUS-CID method: bypassV2 payload: data: mode: manual method: setHumidityMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_mist_level: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-A602S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-A602S-WUS-CID method: bypassV2 payload: data: id: 0 level: 2 type: mist method: setVirtualLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LUH-A602S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-A602S-WUS-CID method: bypassV2 payload: data: enabled: false id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_off_automatic_stop: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-A602S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-A602S-WUS-CID method: bypassV2 payload: data: enabled: false method: setAutomaticStop source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_off_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-A602S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-A602S-WUS-CID method: bypassV2 payload: data: state: false method: setDisplay source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LUH-A602S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-A602S-WUS-CID method: bypassV2 payload: data: enabled: true id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_on_automatic_stop: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-A602S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-A602S-WUS-CID method: bypassV2 payload: data: enabled: true method: setAutomaticStop source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_on_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-A602S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-A602S-WUS-CID method: bypassV2 payload: data: state: true method: setDisplay source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LUH-A602S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-A602S-WUS-CID method: bypassV2 payload: data: {} method: getHumidifierStatus source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 webdjoe-pyvesync-eb8cecb/src/tests/api/vesynchumidifier/LUH-M101S.yaml000066400000000000000000000157251507433633000260140ustar00rootroot00000000000000set_auto_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-M101S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-M101S-CID method: bypassV2 payload: data: workMode: auto method: setHumidityMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_humidity: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-M101S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-M101S-CID method: bypassV2 payload: data: targetHumidity: 50 method: setTargetHumidity source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_manual_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-M101S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-M101S-CID method: bypassV2 payload: data: workMode: manual method: setHumidityMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_mist_level: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-M101S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-M101S-CID method: bypassV2 payload: data: levelIdx: 0 levelType: mist virtualLevel: 2 method: virtualLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LUH-M101S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-M101S-CID method: bypassV2 payload: data: powerSwitch: 0 switchIdx: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_off_automatic_stop: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-M101S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-M101S-CID method: bypassV2 payload: data: autoStopSwitch: 0 method: setAutoStopSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_off_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-M101S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-M101S-CID method: bypassV2 payload: data: screenSwitch: 0 method: setDisplay source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LUH-M101S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-M101S-CID method: bypassV2 payload: data: powerSwitch: 1 switchIdx: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_on_automatic_stop: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-M101S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-M101S-CID method: bypassV2 payload: data: autoStopSwitch: 1 method: setAutoStopSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_on_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-M101S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-M101S-CID method: bypassV2 payload: data: screenSwitch: 1 method: setDisplay source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LUH-M101S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-M101S-CID method: bypassV2 payload: data: {} method: getHumidifierStatus source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 webdjoe-pyvesync-eb8cecb/src/tests/api/vesynchumidifier/LUH-O451S-WEU.yaml000066400000000000000000000157701507433633000264640ustar00rootroot00000000000000set_auto_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-O451S-WEU-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WEU-CID method: bypassV2 payload: data: mode: auto method: setHumidityMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_humidity: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-O451S-WEU-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WEU-CID method: bypassV2 payload: data: target_humidity: 50 method: setTargetHumidity source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_manual_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-O451S-WEU-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WEU-CID method: bypassV2 payload: data: mode: manual method: setHumidityMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_mist_level: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-O451S-WEU-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WEU-CID method: bypassV2 payload: data: id: 0 level: 2 type: mist method: setVirtualLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LUH-O451S-WEU-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WEU-CID method: bypassV2 payload: data: enabled: false id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_off_automatic_stop: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-O451S-WEU-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WEU-CID method: bypassV2 payload: data: enabled: false method: setAutomaticStop source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_off_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-O451S-WEU-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WEU-CID method: bypassV2 payload: data: state: false method: setDisplay source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LUH-O451S-WEU-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WEU-CID method: bypassV2 payload: data: enabled: true id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_on_automatic_stop: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-O451S-WEU-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WEU-CID method: bypassV2 payload: data: enabled: true method: setAutomaticStop source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_on_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-O451S-WEU-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WEU-CID method: bypassV2 payload: data: state: true method: setDisplay source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LUH-O451S-WEU-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WEU-CID method: bypassV2 payload: data: {} method: getHumidifierStatus source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 webdjoe-pyvesync-eb8cecb/src/tests/api/vesynchumidifier/LUH-O451S-WUS.yaml000066400000000000000000000157701507433633000265020ustar00rootroot00000000000000set_auto_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-O451S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WUS-CID method: bypassV2 payload: data: mode: auto method: setHumidityMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_humidity: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-O451S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WUS-CID method: bypassV2 payload: data: target_humidity: 50 method: setTargetHumidity source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_manual_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-O451S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WUS-CID method: bypassV2 payload: data: mode: manual method: setHumidityMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_mist_level: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-O451S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WUS-CID method: bypassV2 payload: data: id: 0 level: 2 type: mist method: setVirtualLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LUH-O451S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WUS-CID method: bypassV2 payload: data: enabled: false id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_off_automatic_stop: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-O451S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WUS-CID method: bypassV2 payload: data: enabled: false method: setAutomaticStop source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_off_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-O451S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WUS-CID method: bypassV2 payload: data: state: false method: setDisplay source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LUH-O451S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WUS-CID method: bypassV2 payload: data: enabled: true id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_on_automatic_stop: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-O451S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WUS-CID method: bypassV2 payload: data: enabled: true method: setAutomaticStop source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_on_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LUH-O451S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WUS-CID method: bypassV2 payload: data: state: true method: setDisplay source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LUH-O451S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LUH-O451S-WUS-CID method: bypassV2 payload: data: {} method: getHumidifierStatus source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncoutlet/000077500000000000000000000000001507433633000231165ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncoutlet/BSDOG01.yaml000066400000000000000000000035111507433633000250010ustar00rootroot00000000000000update: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: BSDOG01-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: BSDOG01-CID method: bypassV2 payload: data: {} method: getProperty source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: BSDOG01-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: BSDOG01-CID method: bypassV2 payload: data: powerSwitch_1: 0 method: setProperty source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: BSDOG01-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: BSDOG01-CID method: bypassV2 payload: data: powerSwitch_1: 1 method: setProperty source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncoutlet/ESO15-TB.yaml000066400000000000000000000063461507433633000251120ustar00rootroot00000000000000update: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESO15-TB-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESO15-TB-CID method: deviceDetail phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESO15-TB-UUID method: post url: /cloud/v1/deviceManaged/deviceDetail get_monthly_energy: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 debugMode: false homeTimeZone: America/New_York method: getLastMonthEnergy phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESO15-TB-UUID method: post url: /cloud/v1/device/getLastMonthEnergy get_weekly_energy: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 debugMode: false homeTimeZone: America/New_York method: getLastWeekEnergy phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESO15-TB-UUID method: post url: /cloud/v1/device/getLastWeekEnergy get_yearly_energy: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 debugMode: false homeTimeZone: America/New_York method: getLastYearEnergy phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESO15-TB-UUID method: post url: /cloud/v1/device/getLastYearEnergy turn_off: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESO15-TB-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESO15-TB-CID method: deviceStatus phoneBrand: pyvesync phoneOS: Android status: 'off' switchNo: '1' timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESO15-TB-UUID method: post url: /cloud/v1/deviceManaged/deviceStatus turn_on: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESO15-TB-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESO15-TB-CID method: deviceStatus phoneBrand: pyvesync phoneOS: Android status: 'on' switchNo: '1' timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESO15-TB-UUID method: post url: /cloud/v1/deviceManaged/deviceStatus webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncoutlet/ESW01-EU.yaml000066400000000000000000000052461507433633000251170ustar00rootroot00000000000000update: headers: accept-language: en accountId: sample_id appVersion: 5.6.60 content-type: application/json tk: sample_tk tz: America/New_York json_object: acceptLanguage: en accountId: sample_id appVersion: 5.6.60 method: devicedetail mobileId: 'MOBILE_ID' phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID uuid: ESW01-EU-UUID method: post url: /10a/v1/device/devicedetail get_monthly_energy: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 debugMode: false homeTimeZone: America/New_York method: getLastMonthEnergy phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESW01-EU-UUID method: post url: /cloud/v1/device/getLastMonthEnergy get_weekly_energy: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 debugMode: false homeTimeZone: America/New_York method: getLastWeekEnergy phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESW01-EU-UUID method: post url: /cloud/v1/device/getLastWeekEnergy get_yearly_energy: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 debugMode: false homeTimeZone: America/New_York method: getLastYearEnergy phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESW01-EU-UUID method: post url: /cloud/v1/device/getLastYearEnergy turn_off: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 accountid: sample_id tk: sample_tk tz: America/New_York json_object: accountID: sample_id status: 'off' timeZone: America/New_York token: sample_tk uuid: ESW01-EU-UUID method: put url: /10a/v1/device/devicestatus turn_on: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 accountid: sample_id tk: sample_tk tz: America/New_York json_object: accountID: sample_id status: 'on' timeZone: America/New_York token: sample_tk uuid: ESW01-EU-UUID method: put url: /10a/v1/device/devicestatus webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncoutlet/ESW03-USA.yaml000066400000000000000000000035611507433633000252360ustar00rootroot00000000000000update: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESW03-USA-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESW03-USA-CID method: bypassV2 payload: data: id: 0 method: getSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: ESW03-USA-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESW03-USA-CID method: bypassV2 payload: data: enabled: false id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: ESW03-USA-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESW03-USA-CID method: bypassV2 payload: data: enabled: true id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncoutlet/ESW15-USA.yaml000066400000000000000000000106301507433633000252340ustar00rootroot00000000000000update: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESW15-USA-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESW15-USA-CID method: deviceDetail phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESW15-USA-UUID method: post url: /cloud/v1/deviceManaged/deviceDetail get_monthly_energy: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 debugMode: false homeTimeZone: America/New_York method: getLastMonthEnergy phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESW15-USA-UUID method: post url: /cloud/v1/device/getLastMonthEnergy get_weekly_energy: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 debugMode: false homeTimeZone: America/New_York method: getLastWeekEnergy phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESW15-USA-UUID method: post url: /cloud/v1/device/getLastWeekEnergy get_yearly_energy: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 debugMode: false homeTimeZone: America/New_York method: getLastYearEnergy phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESW15-USA-UUID method: post url: /cloud/v1/device/getLastYearEnergy turn_off: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESW15-USA-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESW15-USA-CID method: deviceStatus phoneBrand: pyvesync phoneOS: Android status: 'off' timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESW15-USA-UUID method: post url: /cloud/v1/deviceManaged/deviceStatus turn_off_nightlight: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESW15-USA-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESW15-USA-CID method: outletNightLightCtl mode: 'off' phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESW15-USA-UUID method: post url: /cloud/v1/deviceManaged/outletNightLightCtl turn_on: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESW15-USA-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESW15-USA-CID method: deviceStatus phoneBrand: pyvesync phoneOS: Android status: 'on' timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESW15-USA-UUID method: post url: /cloud/v1/deviceManaged/deviceStatus turn_on_nightlight: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESW15-USA-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESW15-USA-CID method: outletNightLightCtl mode: 'on' phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESW15-USA-UUID method: post url: /cloud/v1/deviceManaged/outletNightLightCtl webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncoutlet/wifi-switch-1.3.yaml000066400000000000000000000043171507433633000265430ustar00rootroot00000000000000update: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 accountid: sample_id tk: sample_tk tz: America/New_York method: get url: /v1/device/wifi-switch-1.3-CID/detail get_monthly_energy: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 debugMode: false homeTimeZone: America/New_York method: getLastMonthEnergy phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: wifi-switch-1.3-UUID method: post url: /cloud/v1/device/getLastMonthEnergy get_weekly_energy: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 debugMode: false homeTimeZone: America/New_York method: getLastWeekEnergy phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: wifi-switch-1.3-UUID method: post url: /cloud/v1/device/getLastWeekEnergy get_yearly_energy: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 debugMode: false homeTimeZone: America/New_York method: getLastYearEnergy phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: wifi-switch-1.3-UUID method: post url: /cloud/v1/device/getLastYearEnergy turn_off: headers: accept-language: en accountId: sample_id appVersion: 5.6.60 content-type: application/json tk: sample_tk tz: America/New_York method: put url: /v1/wifi-switch-1.3/wifi-switch-1.3-CID/status/off turn_on: headers: accept-language: en accountId: sample_id appVersion: 5.6.60 content-type: application/json tk: sample_tk tz: America/New_York method: put url: /v1/wifi-switch-1.3/wifi-switch-1.3-CID/status/on webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncpurifier/000077500000000000000000000000001507433633000234275ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncpurifier/Core200S.yaml000066400000000000000000000074151507433633000255570ustar00rootroot00000000000000set_fan_speed: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Core200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core200S-CID method: bypassV2 payload: data: id: 0 level: 3 type: wind method: setLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_manual_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Core200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core200S-CID method: bypassV2 payload: data: id: 0 level: 1 type: wind method: setLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_sleep_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Core200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core200S-CID method: bypassV2 payload: data: mode: sleep method: setPurifierMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: Core200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core200S-CID method: bypassV2 payload: data: enabled: false id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: Core200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core200S-CID method: bypassV2 payload: data: enabled: true id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: Core200S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core200S-CID method: bypassV2 payload: data: {} method: getPurifierStatus source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncpurifier/Core300S.yaml000066400000000000000000000131741507433633000255570ustar00rootroot00000000000000set_auto_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Core300S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core300S-CID method: bypassV2 payload: data: mode: auto method: setPurifierMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_fan_speed: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Core300S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core300S-CID method: bypassV2 payload: data: id: 0 level: 3 type: wind method: setLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_manual_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Core300S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core300S-CID method: bypassV2 payload: data: id: 0 level: 1 type: wind method: setLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_sleep_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Core300S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core300S-CID method: bypassV2 payload: data: mode: sleep method: setPurifierMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: Core300S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core300S-CID method: bypassV2 payload: data: enabled: false id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_off_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Core300S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core300S-CID method: bypassV2 payload: data: state: false method: setDisplay source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: Core300S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core300S-CID method: bypassV2 payload: data: enabled: true id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_on_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Core300S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core300S-CID method: bypassV2 payload: data: state: true method: setDisplay source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: Core300S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core300S-CID method: bypassV2 payload: data: {} method: getPurifierStatus source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncpurifier/Core400S.yaml000066400000000000000000000131741507433633000255600ustar00rootroot00000000000000set_auto_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Core400S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core400S-CID method: bypassV2 payload: data: mode: auto method: setPurifierMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_fan_speed: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Core400S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core400S-CID method: bypassV2 payload: data: id: 0 level: 3 type: wind method: setLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_manual_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Core400S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core400S-CID method: bypassV2 payload: data: id: 0 level: 1 type: wind method: setLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_sleep_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Core400S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core400S-CID method: bypassV2 payload: data: mode: sleep method: setPurifierMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: Core400S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core400S-CID method: bypassV2 payload: data: enabled: false id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_off_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Core400S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core400S-CID method: bypassV2 payload: data: state: false method: setDisplay source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: Core400S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core400S-CID method: bypassV2 payload: data: enabled: true id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_on_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Core400S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core400S-CID method: bypassV2 payload: data: state: true method: setDisplay source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: Core400S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core400S-CID method: bypassV2 payload: data: {} method: getPurifierStatus source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncpurifier/Core600S.yaml000066400000000000000000000131741507433633000255620ustar00rootroot00000000000000set_auto_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Core600S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core600S-CID method: bypassV2 payload: data: mode: auto method: setPurifierMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_fan_speed: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Core600S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core600S-CID method: bypassV2 payload: data: id: 0 level: 3 type: wind method: setLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_manual_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Core600S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core600S-CID method: bypassV2 payload: data: id: 0 level: 1 type: wind method: setLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_sleep_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Core600S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core600S-CID method: bypassV2 payload: data: mode: sleep method: setPurifierMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: Core600S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core600S-CID method: bypassV2 payload: data: enabled: false id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_off_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Core600S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core600S-CID method: bypassV2 payload: data: state: false method: setDisplay source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: Core600S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core600S-CID method: bypassV2 payload: data: enabled: true id: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 turn_on_display: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: Core600S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core600S-CID method: bypassV2 payload: data: state: true method: setDisplay source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: Core600S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: Core600S-CID method: bypassV2 payload: data: {} method: getPurifierStatus source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncpurifier/EL551S.yaml000066400000000000000000000074641507433633000252040ustar00rootroot00000000000000set_fan_speed: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: EL551S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: EL551S-CID method: bypassV2 payload: data: levelIdx: 0 levelType: wind manualSpeedLevel: 3 method: setLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_manual_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: EL551S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: EL551S-CID method: bypassV2 payload: data: levelIdx: 0 levelType: wind manualSpeedLevel: 1 method: setLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_sleep_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: EL551S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: EL551S-CID method: bypassV2 payload: data: workMode: sleep method: setPurifierMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: EL551S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: EL551S-CID method: bypassV2 payload: data: powerSwitch: 0 switchIdx: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: EL551S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: EL551S-CID method: bypassV2 payload: data: powerSwitch: 1 switchIdx: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: EL551S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: EL551S-CID method: bypassV2 payload: data: {} method: getPurifierStatus source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncpurifier/LAP-B851S-WUS.yaml000066400000000000000000000076101507433633000261470ustar00rootroot00000000000000set_fan_speed: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LAP-B851S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LAP-B851S-WUS-CID method: bypassV2 payload: data: levelIdx: 0 levelType: wind manualSpeedLevel: 3 method: setLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_manual_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LAP-B851S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LAP-B851S-WUS-CID method: bypassV2 payload: data: levelIdx: 0 levelType: wind manualSpeedLevel: 1 method: setLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_sleep_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LAP-B851S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LAP-B851S-WUS-CID method: bypassV2 payload: data: workMode: sleep method: setPurifierMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LAP-B851S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LAP-B851S-WUS-CID method: bypassV2 payload: data: powerSwitch: 0 switchIdx: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LAP-B851S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LAP-B851S-WUS-CID method: bypassV2 payload: data: powerSwitch: 1 switchIdx: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LAP-B851S-WUS-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LAP-B851S-WUS-CID method: bypassV2 payload: data: {} method: getPurifierStatus source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncpurifier/LAP-V102S.yaml000066400000000000000000000075301507433633000255050ustar00rootroot00000000000000set_fan_speed: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LAP-V102S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LAP-V102S-CID method: bypassV2 payload: data: levelIdx: 0 levelType: wind manualSpeedLevel: 3 method: setLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_manual_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LAP-V102S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LAP-V102S-CID method: bypassV2 payload: data: levelIdx: 0 levelType: wind manualSpeedLevel: 1 method: setLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_sleep_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LAP-V102S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LAP-V102S-CID method: bypassV2 payload: data: workMode: sleep method: setPurifierMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LAP-V102S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LAP-V102S-CID method: bypassV2 payload: data: powerSwitch: 0 switchIdx: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LAP-V102S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LAP-V102S-CID method: bypassV2 payload: data: powerSwitch: 1 switchIdx: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LAP-V102S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LAP-V102S-CID method: bypassV2 payload: data: {} method: getPurifierStatus source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncpurifier/LAP-V201S.yaml000066400000000000000000000075301507433633000255050ustar00rootroot00000000000000set_fan_speed: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LAP-V201S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LAP-V201S-CID method: bypassV2 payload: data: levelIdx: 0 levelType: wind manualSpeedLevel: 3 method: setLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_manual_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LAP-V201S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LAP-V201S-CID method: bypassV2 payload: data: levelIdx: 0 levelType: wind manualSpeedLevel: 1 method: setLevel source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 set_sleep_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LAP-V201S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LAP-V201S-CID method: bypassV2 payload: data: workMode: sleep method: setPurifierMode source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LAP-V201S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LAP-V201S-CID method: bypassV2 payload: data: powerSwitch: 0 switchIdx: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LAP-V201S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LAP-V201S-CID method: bypassV2 payload: data: powerSwitch: 1 switchIdx: 0 method: setSwitch source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US 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: 5.6.60 cid: LAP-V201S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LAP-V201S-CID method: bypassV2 payload: data: {} method: getPurifierStatus source: APP phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncpurifier/LV-PUR131S.yaml000066400000000000000000000070761507433633000256620ustar00rootroot00000000000000set_fan_speed: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LV-PUR131S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LV-PUR131S-CID level: 3 method: airPurifierSpeedCtl phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: LV-PUR131S-UUID method: post url: /cloud/v1/deviceManaged/airPurifierSpeedCtl set_manual_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LV-PUR131S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LV-PUR131S-CID level: 1 method: airPurifierSpeedCtl phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: LV-PUR131S-UUID method: post url: /cloud/v1/deviceManaged/airPurifierSpeedCtl set_sleep_mode: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LV-PUR131S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LV-PUR131S-CID method: airPurifierRunModeCtl mode: sleep phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: LV-PUR131S-UUID method: post url: /cloud/v1/deviceManaged/airPurifierRunModeCtl turn_off: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LV-PUR131S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LV-PUR131S-CID method: airPurifierPowerSwitchCtl phoneBrand: pyvesync phoneOS: Android status: 'off' timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: LV-PUR131S-UUID method: post url: /cloud/v1/deviceManaged/airPurifierPowerSwitchCtl turn_on: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LV-PUR131S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LV-PUR131S-CID method: airPurifierPowerSwitchCtl phoneBrand: pyvesync phoneOS: Android status: 'on' timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: LV-PUR131S-UUID method: post url: /cloud/v1/deviceManaged/airPurifierPowerSwitchCtl update: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: LV-PUR131S-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: LV-PUR131S-CID method: deviceDetail phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: LV-PUR131S-UUID method: post url: /cloud/v1/deviceManaged/deviceDetail webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncswitch/000077500000000000000000000000001507433633000231035ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncswitch/ESWD16.yaml000066400000000000000000000102261507433633000247010ustar00rootroot00000000000000set_backlight_color: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESWD16-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESWD16-CID method: dimmerRgbValueCtl phoneBrand: pyvesync phoneOS: Android rgbValue: blue: 225 green: 100 red: 50 status: 'on' timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESWD16-UUID method: post url: /cloud/v1/deviceManaged/dimmerRgbValueCtl set_brightness: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 brightness: '75' cid: ESWD16-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESWD16-CID method: dimmerBrightnessCtl phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESWD16-UUID method: post url: /cloud/v1/deviceManaged/dimmerBrightnessCtl turn_off: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESWD16-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESWD16-CID method: dimmerPowerSwitchCtl phoneBrand: pyvesync phoneOS: Android status: 'off' timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESWD16-UUID method: post url: /cloud/v1/deviceManaged/dimmerPowerSwitchCtl turn_on: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESWD16-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESWD16-CID method: dimmerPowerSwitchCtl phoneBrand: pyvesync phoneOS: Android status: 'on' timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESWD16-UUID method: post url: /cloud/v1/deviceManaged/dimmerPowerSwitchCtl turn_on_indicator_light: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESWD16-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESWD16-CID method: dimmerIndicatorLightCtl phoneBrand: pyvesync phoneOS: Android status: 'on' timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESWD16-UUID method: post url: /cloud/v1/deviceManaged/dimmerIndicatorLightCtl turn_on_rgb_backlight: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESWD16-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESWD16-CID method: dimmerRgbValueCtl phoneBrand: pyvesync phoneOS: Android status: 'on' timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESWD16-UUID method: post url: /cloud/v1/deviceManaged/dimmerRgbValueCtl update: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESWD16-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESWD16-CID method: deviceDetail phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESWD16-UUID method: post url: /cloud/v1/deviceManaged/deviceDetail webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncswitch/ESWL01.yaml000066400000000000000000000033341507433633000247050ustar00rootroot00000000000000turn_off: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESWL01-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESWL01-CID method: deviceStatus phoneBrand: pyvesync phoneOS: Android status: 'off' switchNo: 0 timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESWL01-UUID method: post url: /cloud/v1/deviceManaged/deviceStatus turn_on: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESWL01-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESWL01-CID method: deviceStatus phoneBrand: pyvesync phoneOS: Android status: 'on' switchNo: 0 timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESWL01-UUID method: post url: /cloud/v1/deviceManaged/deviceStatus update: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESWL01-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESWL01-CID method: deviceDetail phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESWL01-UUID method: post url: /cloud/v1/deviceManaged/deviceDetail webdjoe-pyvesync-eb8cecb/src/tests/api/vesyncswitch/ESWL03.yaml000066400000000000000000000033341507433633000247070ustar00rootroot00000000000000turn_off: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESWL03-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESWL03-CID method: deviceStatus phoneBrand: pyvesync phoneOS: Android status: 'off' switchNo: 0 timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESWL03-UUID method: post url: /cloud/v1/deviceManaged/deviceStatus turn_on: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESWL03-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESWL03-CID method: deviceStatus phoneBrand: pyvesync phoneOS: Android status: 'on' switchNo: 0 timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESWL03-UUID method: post url: /cloud/v1/deviceManaged/deviceStatus update: headers: Content-Type: application/json; charset=UTF-8 User-Agent: okhttp/3.12.1 json_object: acceptLanguage: en accountID: sample_id appVersion: 5.6.60 cid: ESWL03-CID configModel: ConfigModule configModule: ConfigModule debugMode: false deviceId: ESWL03-CID method: deviceDetail phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York token: sample_tk traceId: TRACE_ID userCountryCode: US uuid: ESWL03-UUID method: post url: /cloud/v1/deviceManaged/deviceDetail webdjoe-pyvesync-eb8cecb/src/tests/base_test_cases.py000066400000000000000000000111421507433633000233010ustar00rootroot00000000000000"""Contains base test cases for mocking devices and API calls.""" from __future__ import annotations from typing import TYPE_CHECKING import asyncio import logging import pytest from unittest.mock import MagicMock, patch from pyvesync import VeSync from pyvesync.models.vesync_models import ResponseDeviceDetailsModel from defaults import TestDefaults, API_DEFAULTS from call_json import ALL_DEVICE_MAP_DICT, DeviceList if TYPE_CHECKING: from pyvesync.base_devices import VeSyncBaseDevice class TestApiFunc: """Test case to instantiate vs object and patch the session object.""" default_endpoint = '/endpoint' @pytest.fixture(autouse=True, scope='function') def setup(self, caplog): """Fixture to instantiate VeSync object, start logging and start Mock. This fixture mocks the ClientSession object directly. Yields: self: Class instance with mocked session object to test response handling. """ self.mock_api_call = patch("pyvesync.vesync.ClientSession") self.caplog = caplog self.caplog.set_level(logging.DEBUG) self.mock_api = self.mock_api_call.start() self.loop = asyncio.new_event_loop() self.mock = MagicMock() self.manager = VeSync(API_DEFAULTS['EMAIL'], API_DEFAULTS['PASSWORD']) self.manager.verbose = True self.manager.enabled = True self.manager.redact = False self.manager.auth._token = TestDefaults.token self.manager.auth._account_id = TestDefaults.account_id caplog.set_level(logging.DEBUG) yield self.mock.stop() self.loop.stop() async def run_coro(self, coro): """Run a coroutine in the event loop.""" return await coro def run_in_loop(self, func, *args, **kwargs): """Run a function in the event loop.""" return self.loop.run_until_complete(self.run_coro(func(*args, **kwargs))) class TestBase: """Base class for tests with the call_api() method mocked. Contains instantiated VeSync object and mocked API call for call_api() function. Attributes: mock_api (Mock): Mock for call_api() function. manager (VeSync): Instantiated VeSync object that is logged in. caplog (LogCaptureFixture): Pytest fixture for capturing logs. """ overwrite = False write_api = False @pytest.fixture(autouse=True, scope='function') def setup(self, caplog): """Fixture to instantiate VeSync object, start logging and start Mock. Attributes ---------- self.mock_api : Mock self.manager : VeSync self.caplog : LogCaptureFixture Yields ------ Class instance with mocked call_api() function and VeSync object """ self.loop = asyncio.new_event_loop() self.mock_api_call = patch('pyvesync.vesync.VeSync.async_call_api') self.caplog = caplog self.caplog.set_level(logging.DEBUG) self.mock_api = self.mock_api_call.start() self.mock_api.return_value.ok = True self.manager = VeSync(TestDefaults.email, TestDefaults.password) self.manager.debug = True self.manager.verbose = True self.manager.redact = False self.manager.time_zone = TestDefaults.time_zone self.manager.enabled = True self.manager.auth._token = TestDefaults.token self.manager.auth._account_id = TestDefaults.account_id caplog.set_level(logging.DEBUG) yield self.mock_api_call.stop() async def run_coro(self, coro): """Run a coroutine in the event loop.""" return await coro def run_in_loop(self, func, *args, **kwargs) -> VeSyncBaseDevice | None: """Run a function in the event loop.""" return self.loop.run_until_complete(self.run_coro(func(*args, **kwargs))) def get_device(self, product_type: str, setup_entry: str) -> VeSyncBaseDevice: """Get device from device details dict. Args: device_details (dict): Device details dictionary from call_json module. Returns: Device object from VeSync.devices """ if len(self.manager.devices) > 0: self.manager.devices.clear() device_map = ALL_DEVICE_MAP_DICT[setup_entry] device_config = DeviceList.device_list_item(device_map) self.manager.devices.add_device_from_model( ResponseDeviceDetailsModel.from_dict(device_config), self.manager ) device_list = getattr(self.manager.devices, product_type) assert len(device_list) == 1, f"Could not instantiate {product_type} device." return device_list[0] webdjoe-pyvesync-eb8cecb/src/tests/call_json.py000066400000000000000000000610161507433633000221230ustar00rootroot00000000000000import copy from typing import Any import pyvesync.const as const from defaults import TestDefaults from pyvesync.device_map import DeviceMapTemplate import call_json_switches import call_json_outlets import call_json_bulbs import call_json_fans import call_json_humidifiers import call_json_purifiers API_BASE_URL = const.API_BASE_URL API_TIMEOUT = const.API_TIMEOUT DEFAULT_TZ = const.DEFAULT_TZ APP_VERSION = const.APP_VERSION PHONE_BRAND = const.PHONE_BRAND PHONE_OS = const.PHONE_OS MOBILE_ID = const.MOBILE_ID USER_TYPE = const.USER_TYPE ALL_DEVICE_MAP_MODULES: list[DeviceMapTemplate] = [ *call_json_bulbs.bulb_modules, *call_json_fans.fan_modules, *call_json_outlets.outlet_modules, *call_json_switches.switch_modules, *call_json_humidifiers.humidifier_modules, *call_json_purifiers.purifier_modules ] ALL_DEVICE_MAP_DICT: dict[str, DeviceMapTemplate] = { module.setup_entry: module for module in ALL_DEVICE_MAP_MODULES } """Contains dictionary of setup_entry: DeviceMapTemplate for all devices.""" """ DEFAULT_BODY = Standard body for new device calls DEFAULT_HEADER = standard header for most calls DEFAULT_HEADER_BYPASS = standard header for most calls api V2 ENERGY_HISTORY = standard outlet energy history response ------------------------------------------------------- login_call_body(email, pass) = body of login call LOGIN_RET_BODY = return of login call ------------------------------------------------------- get_devices_body() = body of call to get device list LIST_CONF_10AEU = device list entry for 10A Europe outlet LIST_CONF_10AUS = devlice list entry for 10A US outlet LIST_CONF_7A = device list entry for 7A outlet LIST_CONF_AIR = device list entry for air purifier LIST_CONF_15A = device list entry for 15A outlet LIST_CONF_WS = device list entry for wall switch LIST_CONF_ESL100 = device list entry for bulb ESL100 LIST_CONF_OUTDOOR_1 = devlice list entry for outdoor outlet subDevice 1 LIST_CONF_OUTDOOR_2 = devlice list entry for outdoor outlet subDevice 2 DEVLIST_ALL = Return tuple for all devices DEVLIST_10AEU = device list return for only 10A eu outlet DEVLIST_10AUS = device list return for only 10A us outlet DEVLIST_7A = device list return for only 7A outlet DEVLIST_15A = device list return for only 15A outlet DEVLIST_WS = device list return for only wall switch DEVLIST_AIR = device list return for just air purifier DEVLIST_ESL100 = device list return for just ESL100 bulb DEVLIST_OUTDOOR_1 = device list return for outdoor outlet subDevice 1 DEVLIST_OUTDOOR_2 = device list return for outdoor outlet subDevice 2 --------------------------------------------------------- DETAILS_10A = Return for 10A outlet device details DETAILS_15A = Return for 15A outlet device details DETAILS_7A = Return for 7A outlet device details DETAILS_WS = Return for wall switch device details DETAILS_AIR = Return for Air Purifier device details DETAILS_ESL100 = Return for ESL100 Bulb device details DETAILS_OUTDOOR = return for 2 plug outdoor outlet """ BULBS = call_json_bulbs.BULBS FANS = call_json_fans.FANS OUTLETS = call_json_outlets.OUTLETS SWITCHES = call_json_switches.SWITCHES DEFAULT_HEADER = { 'accept-language': 'en', 'accountId': TestDefaults.account_id, 'appVersion': APP_VERSION, 'content-type': 'application/json', 'tk': TestDefaults.token, 'tz': DEFAULT_TZ, } DEFAULT_HEADER_BYPASS = { 'Content-Type': 'application/json; charset=UTF-8', 'User-Agent': 'okhttp/3.12.1' } def response_body(code: int = 0, msg: str | None = 'Success') -> dict[str, Any]: """Return base response dictionary.""" return { 'code': code, 'traceId': TestDefaults.trace_id, 'msg': msg } class LoginResponses: GET_TOKEN_RESPONSE_SUCCESS = { "traceId": TestDefaults.trace_id, "code": 0, "msg": None, "result": { "accountID": TestDefaults.account_id, "avatarIcon": "", "nickName": "", "mailConfirmation": True, "registerSourceDetail": None, "verifyEmail": TestDefaults.email, "bizToken": None, "mfaMethodList": None, "authorizeCode": TestDefaults.authorization_code, "userType": "1", }, } LOGIN_RESPONSE_SUCCESS = { "traceId": TestDefaults.trace_id, "code": 0, "msg": "request success", "module": None, "stacktrace": None, "result": { "birthday": "", "gender": "", "acceptLanguage": "en", "measureUnit": "Imperial", "weightG": 0.0, "heightCm": 0.0, "weightTargetSt": 0.0, "heightUnit": "FT", "heightFt": 0.0, "weightTargetKg": 0.0, "weightTargetLb": 0.0, "weightUnit": "LB", "maximalHeartRate": 0, "targetBfr": 0.0, "displayFlag": [], "targetStatus": 0, "realWeightKg": 0.0, "realWeightLb": 0.0, "realWeightUnit": "lb", "heartRateZones": 0.0, "runStepLongCm": 0.0, "walkStepLongCm": 0.0, "stepTarget": 0.0, "sleepTargetMins": 0.0, "regionType": 1, "currentRegion": TestDefaults.region, "token": TestDefaults.token, "countryCode": TestDefaults.country_code, "accountID": TestDefaults.account_id, "bizToken": None, }, } LOGIN_RESPONSE_CROSS_REGION = { "traceId": "TRACE_ID", "code": -11260022, "msg": "login trigger cross region error.", "module": None, "stacktrace": None, "result": { "birthday": None, "gender": None, "acceptLanguage": None, "measureUnit": None, "weightG": None, "heightCm": None, "weightTargetSt": None, "heightUnit": None, "heightFt": None, "weightTargetKg": None, "weightTargetLb": None, "weightUnit": None, "maximalHeartRate": None, "targetBfr": None, "displayFlag": None, "targetStatus": None, "realWeightKg": None, "realWeightLb": None, "realWeightUnit": None, "heartRateZones": None, "runStepLongCm": None, "walkStepLongCm": None, "stepTarget": None, "sleepTargetMins": None, "regionType": 1, "currentRegion": TestDefaults.region, "token": None, "countryCode": TestDefaults.country_code, "accountID": TestDefaults.account_id, "bizToken": TestDefaults.biz_token, }, } class LoginRequests: authentication_success = { "acceptLanguage": "en", "accountID": "", "clientInfo": const.PHONE_BRAND, "clientType": const.CLIENT_TYPE, "clientVersion": const.APP_VERSION, "debugMode": False, "method": "authByPWDOrOTM", "osInfo": const.PHONE_OS, "terminalId": TestDefaults.terminal_id, "timeZone": const.DEFAULT_TZ, "token": "", "traceId": TestDefaults.trace_id, "userCountryCode": TestDefaults.country_code, "sourceAppID": TestDefaults.app_id, "appID": TestDefaults.app_id, "authProtocolType": "generic", "email": TestDefaults.email, "password": TestDefaults.password, } login_success = { "acceptLanguage": "en", "accountID": "", "clientInfo": const.PHONE_BRAND, "clientType": const.CLIENT_TYPE, "clientVersion": const.APP_VERSION, "debugMode": False, "method": "loginByAuthorizeCode4Vesync", "osInfo": const.PHONE_OS, "terminalId": TestDefaults.terminal_id, "timeZone": const.DEFAULT_TZ, "token": "", "traceId": TestDefaults.trace_id, "userCountryCode": TestDefaults.country_code, "authorizeCode": TestDefaults.authorization_code, "emailSubscriptions": False, } login_cross_region = { "acceptLanguage": "en", "accountID": "", "clientInfo": const.PHONE_BRAND, "clientType": const.CLIENT_TYPE, "clientVersion": const.APP_VERSION, "debugMode": False, "method": "loginByAuthorizeCode4Vesync", "osInfo": const.PHONE_OS, "terminalId": "2124569cc4905328aac52b39669b1b15e", "timeZone": const.DEFAULT_TZ, "token": "", "traceId": TestDefaults.trace_id, "userCountryCode": TestDefaults.country_code, "bizToken": TestDefaults.biz_token, "emailSubscriptions": False, "regionChange": "lastRegion", } def BYPASS_V1_BODY(cid: str, config_module: str, json_cmd: dict): return { "traceId": TestDefaults.trace_id, "method": "bypass", "token": TestDefaults.token, "accountID": TestDefaults.account_id, "timeZone": DEFAULT_TZ, "acceptLanguage": "en", "appVersion": APP_VERSION, "phoneBrand": PHONE_BRAND, "phoneOS": PHONE_OS, "cid": cid, "configModule": config_module, "jsonCmd": json_cmd } def login_call_body(email, password): json_object = { 'acceptLanguage': 'en', 'appVersion': APP_VERSION, 'devToken': '', 'email': email, 'method': 'login', 'password': password, 'phoneBrand': PHONE_BRAND, 'phoneOS': PHONE_OS, 'timeZone': DEFAULT_TZ, 'traceId': TestDefaults.trace_id, 'userType': '1', } return json_object def device_list_item(device_map: DeviceMapTemplate) -> dict: """Create a device list item from a device map. Parameters ---------- device_map : DeviceMapTemplate Device map to create device list item from Returns ------- dict Device list item dictionary """ return { "deviceType": device_map.dev_types[0], "isOwner": True, "deviceName": TestDefaults.name(device_map.setup_entry), "deviceImg": "", "cid": TestDefaults.cid(device_map.setup_entry), "uuid": TestDefaults.uuid(device_map.setup_entry), "macID": TestDefaults.macid(device_map.setup_entry), "connectionType": "wifi", "type": device_map.product_type, "configModule": TestDefaults.config_module(device_map.setup_entry), "mode": None, "speed": None, "currentFirmVersion": None, "speed": None, "subDeviceNo": None, "extension": None, "deviceProps": None, "deviceStatus": const.DeviceStatus.ON, "connectionStatus": const.ConnectionStatus.ONLINE, "product_type": device_map.product_type } class DeviceList: list_response_base: dict[str, Any] = { 'code': 0, 'traceId': TestDefaults.trace_id, 'msg': 'Success', 'result': { 'pageNo': 1, 'pageSize': 100, 'total': 0, 'list': [], } } device_list_base: dict[str, Any] = { 'extension': None, 'isOwner': True, 'authKey': None, 'deviceStatus': 'on', 'connectionStatus': 'online', 'connectionType': 'wifi', 'mode': None, 'speed': None, 'deviceProps': None, 'configModule': 'ConfigModule', 'deviceRegion': TestDefaults.country_code, } bulbs = dict.fromkeys(call_json_bulbs.BULBS, "wifi-light") outlets = dict.fromkeys(call_json_outlets.OUTLETS, "wifi-switch") fans = dict.fromkeys(call_json_fans.FANS, "wifi-air") switches = dict.fromkeys(call_json_switches.SWITCHES, "Switches") # purifiers = dict.fromkeys(call_json_purifiers.) @classmethod def device_list_item(cls, module: DeviceMapTemplate): model_types = {**cls.bulbs, **cls.outlets, **cls.fans, **cls.switches} device_dict = cls.device_list_base model_dict = device_dict.copy() # model_dict['deviceType'] = setup_entry model_dict['deviceName'] = TestDefaults.name(module.setup_entry) model_dict['type'] = model_types.get(module.setup_entry) model_dict['cid'] = TestDefaults.cid(module.setup_entry) model_dict['uuid'] = TestDefaults.uuid(module.setup_entry) model_dict['macID'] = TestDefaults.macid(module.setup_entry) model_dict['deviceType'] = module.dev_types[0] if module.setup_entry == 'ESO15-TB': model_dict['subDeviceNo'] = 1 return model_dict @classmethod def device_list_response( cls, setup_entrys: list[str] | str | None = None, ): """Class method that returns the api get_devices response Args: setup_entrys (list, str optional): List or string of setup_entry(s) to return. Defaults to None. """ if setup_entrys is None: entry_list = ALL_DEVICE_MAP_DICT elif isinstance(setup_entrys, str): entry_list = { k: v for k, v in ALL_DEVICE_MAP_DICT.items() if k == setup_entrys } elif isinstance(setup_entrys, list): entry_list = { k: v for k, v in ALL_DEVICE_MAP_DICT.items() if k in setup_entrys } response_base = copy.deepcopy(cls.list_response_base) response_base['result']['list'] = [] response_base['result']['total'] = 0 for module in entry_list.values(): response_base['result']['list'].append(cls.device_list_item(module)) response_base['result']['total'] += 1 return response_base LIST_CONF_7A = { 'deviceType': 'wifi-switch-1.3', 'extension': None, 'macID': None, 'type': 'wifi-switch', 'deviceName': 'Name 7A Outlet', 'connectionType': 'wifi', 'uuid': None, 'speed': None, 'deviceStatus': 'on', 'mode': None, 'configModule': '7AOutlet', 'currentFirmVersion': '1.95', 'connectionStatus': 'online', 'cid': '7A-CID', } LIST_CONF_15A = { 'deviceType': 'ESW15-USA', 'extension': None, 'macID': None, 'type': 'wifi-switch', 'deviceName': 'Name 15A Outlet', 'connectionType': 'wifi', 'uuid': 'UUID', 'speed': None, 'deviceStatus': 'on', 'mode': None, 'configModule': '15AOutletNightlight', 'currentFirmVersion': None, 'connectionStatus': 'online', 'cid': '15A-CID', } LIST_CONF_WS = { 'deviceType': 'ESWL01', 'extension': None, 'macID': None, 'type': 'Switches', 'deviceName': 'Name Wall Switch', 'connectionType': 'wifi', 'uuid': 'UUID', 'speed': None, 'deviceStatus': 'on', 'mode': None, 'configModule': 'InwallswitchUS', 'currentFirmVersion': None, 'connectionStatus': 'online', 'cid': 'WS-CID', } LIST_CONF_10AEU = { 'deviceType': 'ESW01-EU', 'extension': None, 'macID': None, 'type': 'wifi-switch', 'deviceName': 'Name 10A Outlet', 'connectionType': 'wifi', 'uuid': 'UUID', 'speed': None, 'deviceStatus': 'on', 'mode': None, 'configModule': '10AOutletEU', 'currentFirmVersion': None, 'connectionStatus': 'online', 'cid': '10A-CID', } LIST_CONF_10AUS = { 'deviceType': 'ESW03-USA', 'extension': None, 'macID': None, 'type': 'wifi-switch', 'deviceName': 'Name 10A Outlet', 'connectionType': 'wifi', 'uuid': 'UUID', 'speed': None, 'deviceStatus': 'on', 'mode': None, 'configModule': '10AOutletUSA', 'currentFirmVersion': None, 'connectionStatus': 'online', 'cid': '10A-CID', } LIST_CONF_OUTDOOR_1 = { 'deviceRegion': 'US', 'deviceName': 'Outdoor Socket B', 'cid': 'OUTDOOR-CID', 'deviceStatus': 'on', 'connectionStatus': 'online', 'connectionType': 'wifi', 'deviceType': 'ESO15-TB', 'type': 'wifi-switch', 'uuid': 'UUID', 'configModule': 'OutdoorSocket15A', 'macID': None, 'mode': None, 'speed': None, 'extension': None, 'currentFirmVersion': None, 'subDeviceNo': 1, } LIST_CONF_OUTDOOR_2 = { 'deviceRegion': 'US', 'deviceName': 'Outdoor Socket B', 'cid': 'OUTDOOR-CID', 'deviceStatus': 'on', 'connectionStatus': 'online', 'connectionType': 'wifi', 'deviceType': 'ESO15-TB', 'type': 'wifi-switch', 'uuid': 'UUID', 'configModule': 'OutdoorSocket15A', 'macID': None, 'mode': None, 'speed': None, 'extension': None, 'currentFirmVersion': None, 'subDeviceNo': 2, } LIST_CONF_ESL100 = { 'deviceRegion': 'US', 'deviceName': 'Etekcity Soft White Bulb', 'cid': 'ESL100-CID', 'deviceStatus': 'on', 'connectionStatus': 'online', 'connectionType': 'wifi', 'deviceType': 'ESL100', 'type': 'Wifi-light', 'uuid': 'UUID', 'configModule': 'WifiSmartBulb', 'macID': None, 'mode': None, 'speed': None, 'extension': None, 'currentFirmVersion': None, 'subDeviceNo': None, } LIST_CONF_ESL100CW = { 'deviceRegion': 'US', 'deviceName': 'ESL100CW NAME', 'cid': 'ESL100CW-CID', 'deviceStatus': 'on', 'connectionStatus': 'online', 'connectionType': 'wifi', 'deviceType': 'ESL100CW', 'type': 'Wifi-light', 'uuid': 'ESL100CW-UUID', 'configModule': 'WifiSmartBulb', 'macID': None, 'mode': None, 'speed': None, 'extension': None, 'currentFirmVersion': None, 'subDeviceNo': None, } LIST_CONF_AIR = { 'deviceName': 'Name Air Purifier', 'cid': 'AIRPUR-CID', 'deviceStatus': 'on', 'connectionStatus': 'online', 'connectionType': 'wifi', 'deviceType': 'LV-PUR131S', 'type': 'wifi-air', 'uuid': 'UUID', 'configModule': 'AirPurifier131', 'macID': None, 'mode': 'manual', 'speed': 'low', 'extension': None, 'currentFirmVersion': None, } LIST_CONF_DUAL200S = { 'deviceRegion': 'EU', 'isOwner': True, 'authKey': None, 'deviceName': '200S NAME', 'cid': 'CID-200S', 'deviceStatus': 'on', 'connectionStatus': 'online', 'connectionType': 'WiFi+BTOnboarding+BTNotify', 'deviceType': 'LUH-D301S-WEU', 'type': 'wifi-air', 'uuid': 'UUID-200S', 'configModule': 'WFON_AHM_LUH-D301S-WEU_EU', 'macID': None, 'mode': None, 'speed': None, 'extension': None, 'currentFirmVersion': None, 'subDeviceNo': None, 'subDeviceType': None, } LIST_CONF_DIMMER = { "deviceRegion": "US", "deviceName": "Etekcity Dimmer Switch", "cid": "DIM-CID", "deviceStatus": "on", "connectionStatus": "online", "connectionType": "wifi", "deviceType": "ESWD16", "type": "Switches", "uuid": "DIM-UUID", "configModule": "WifiWallDimmer" } LIST_CONF_600S = { 'deviceRegion': 'US', 'deviceName': 'Bedroom Humidifier', 'deviceImg': '', 'cid': 'CID-600S', 'deviceStatus': 'on', 'connectionStatus': 'online', 'connectionType': 'WiFi+BTOnboarding+BTNotify', 'deviceType': 'LUH-A602S-WUS', 'type': 'wifi-air', 'uuid': 'UUID-600S', 'configModule': 'WFON_AHM_LUH-A602S-WUS_US', 'macID': None, 'subDeviceNo': None, 'subDeviceType': None, 'deviceProp': None } LIST_CONF_ESL100MC = { "cid": "CID-ESL100MC", "uuid": "UUID-ESL100MC", "macID": None, "subDeviceNo": 0, "subDeviceType": None, "deviceName": "ESL100MC NAME", "configModule": "WiFi_Bulb_MulticolorBulb_US", "type": "Wifi-light", "deviceType": "ESL100MC", "deviceStatus": "on", "connectionType": "wifi", "currentFirmVersion": "1.0.12", "connectionStatus": "online", "speed": None, "extension": None, "deviceProp": None } LIST_CONF_LV131S = { 'deviceName': 'LV131S NAME', 'cid': 'CID-LV131S', 'deviceStatus': 'on', 'connectionStatus': 'online', 'connectionType': 'wifi', 'deviceType': 'LV-PUR131S', 'type': 'wifi-air', 'uuid': 'UUID-LV131S', 'configModule': 'AirPurifier131', 'macID': None, 'mode': 'auto', 'speed': None, 'extension': None, 'currentFirmVersion': None, 'subDeviceNo': None, 'subDeviceType': None } LIST_CONF_VALCENO = { "deviceName": "VALCENO NAME", "cid": "CID-VALCENO", "deviceStatus": "on", "connectionStatus": "online", "connectionType": "WiFi+BTOnboarding+BTNotify", "deviceType": "XYD0001", "type": "Wifi-light", "uuid": "UUID-VALCENO", "configModule": "VC_WFON_BLB_A19-MC_US", "macID": None, "subDeviceNo": None, "subDeviceType": None } API_URL = '/cloud/v1/deviceManaged/devices' METHOD = 'POST' FULL_DEV_LIST = [ LIST_CONF_10AEU, LIST_CONF_10AUS, LIST_CONF_15A, LIST_CONF_7A, LIST_CONF_AIR, LIST_CONF_WS, LIST_CONF_ESL100, LIST_CONF_OUTDOOR_1, LIST_CONF_OUTDOOR_2, LIST_CONF_DIMMER, LIST_CONF_600S, LIST_CONF_LV131S, LIST_CONF_DUAL200S, LIST_CONF_ESL100CW, LIST_CONF_ESL100MC, LIST_CONF_VALCENO ] @classmethod def DEVICE_LIST_RETURN(cls, dev_conf: dict) -> tuple: """Test the fan.""" return ( { 'code': 0, 'result': { 'list': [dev_conf] } }, 200 ) FAN_TEST = ({'code': 0, 'result': {'list': [LIST_CONF_600S, LIST_CONF_LV131S, LIST_CONF_DUAL200S]}}, 200) DEVLIST_ALL = ({'code': 0, 'result': {'list': FULL_DEV_LIST}}, 200) DEVLIST_7A = ({'code': 0, 'result': {'list': [LIST_CONF_7A]}}, 200) DEVLIST_15A = ({'code': 0, 'result': {'list': [LIST_CONF_15A]}}, 200) DEVLIST_10AEU = ({'code': 0, 'result': {'list': [LIST_CONF_10AEU]}}, 200) DEVLIST_10AUS = ({'code': 0, 'result': {'list': [LIST_CONF_10AUS]}}, 200) DEVLIST_WS = ({'code': 0, 'result': {'list': [LIST_CONF_WS]}}, 200) DEVLIST_DIMMER = ({'code': 0, 'result': {'list': [LIST_CONF_DIMMER]}}, 200) DEVLIST_AIR = ({'code': 0, 'result': {'list': [LIST_CONF_AIR]}}, 200) DEVLIST_ESL100 = ({'code': 0, 'result': {'list': [LIST_CONF_ESL100]}}, 200) DEVLIST_DUAL200S = ({'code': 0, 'result': {'list': [LIST_CONF_DUAL200S]}}, 200) DEVLIST_OUTDOOR = ( {'code': 0, 'result': {'list': [LIST_CONF_OUTDOOR_1, LIST_CONF_OUTDOOR_2]}}, 200, ) class DeviceDetails: """Responses for get_details() method for all devices. class attributes: outlets : dict Dictionary of outlet responses for each device type. switches : dict Dictionary of switch responses for each device type. bulbs : dict Dictionary of bulb responses for each device type. fans : dict Dictionary of humidifier & air pur responses for each device type. all_devices : dict Dictionary of all device responses for each device type. Example ------- outlets = {'ESW01-EU': {'switches': [{'outlet': 0, 'switch': 'on'}]}} """ outlets = call_json_outlets.DETAILS_RESPONSES switches = call_json_switches.DETAILS_RESPONSES fans = call_json_fans.DETAILS_RESPONSES bulbs = call_json_bulbs.DETAILS_RESPONSES all_devices = { 'outlets': outlets, 'switches': switches, 'fans': fans, 'bulbs': bulbs } DETAILS_BADCODE = { "code": 1000, "deviceImg": "", "activeTime": 1, "energy": 1, "power": "1", "voltage": "1", } STATUS_BODY = { 'accountID': TestDefaults.account_id, 'token': TestDefaults.token, 'uuid': 'UUID', 'timeZone': DEFAULT_TZ, } def off_body(): body = STATUS_BODY body['status'] = 'off' return body, 200 def on_body(): body = STATUS_BODY body['status'] = 'on' return body, 200 webdjoe-pyvesync-eb8cecb/src/tests/call_json_bulbs.py000066400000000000000000000175261507433633000233210ustar00rootroot00000000000000""" Light Bulbs Device API Responses OUTLET variable is a list of device types DETAILS_RESPONSES variable is a dictionary of responses from the API for get_details() methods. The keys are the device types and the values are the responses. The responses are tuples of (response, status) METHOD_RESPONSES variable is a defaultdict of responses from the API. This is the FunctionResponse variable from the utils module in the tests dir. The default response is a tuple with the value ({"code": 0, "msg": "success"}, 200). The values of METHOD_RESPONSES can be a function that takes a single argument or a static value. The value is checked if callable at runtime and if so, it is called with the provided argument. If not callable, the value is returned as is. METHOD_RESPONSES = { 'ESL100': defaultdict( lambda: ({"code": 0, "msg": "success"}, 200)) ) } # For a function to handle the response def status_response(request_body=None): # do work with request_body return request_body, 200 METHOD_RESPONSES['ESL100']['set_status'] = status_response # To change the default value for a device type METHOD_RESPONSES['XYD0001'].default_factory = lambda: ({"code": 0, "msg": "success"}, 200) """ from copy import deepcopy from pyvesync.device_map import bulb_modules from pyvesync.const import DeviceStatus, ConnectionStatus from defaults import ( TestDefaults, FunctionResponsesV2, FunctionResponsesV1, build_bypass_v1_response, build_bypass_v2_response, ) # BULBS = ['ESL100', 'ESL100CW', 'ESL100MC', 'XYD0001'] BULBS = [m.setup_entry for m in bulb_modules] BULBS_NUM = len(BULBS) BULB_DETAILS: dict[str, dict[str, str | float | dict | None]] = { "ESL100": { "deviceName": "Dimmable", "name": "Dimmable", "brightNess": str(TestDefaults.brightness), "deviceStatus": DeviceStatus.ON.value, "activeTime": TestDefaults.active_time, "defaultDeviceImg": "", "timer": None, "scheduleCount": 0, "away": None, "schedule": None, "ownerShip": "1", "deviceImg": "", "connectionStatus": ConnectionStatus.ONLINE.value, }, "ESL100CW": { "light": { "action": DeviceStatus.ON.value, "brightness": TestDefaults.brightness, "colorTempe": TestDefaults.color_temp, } }, "ESL100MC": { "action": DeviceStatus.ON.value, "brightness": TestDefaults.brightness, "colorMode": "color", "speed": 0, "red": TestDefaults.color.rgb.red, "green": TestDefaults.color.rgb.green, "blue": TestDefaults.color.rgb.blue, }, "XYD0001": { "enabled": DeviceStatus.ON.value, "colorMode": "color", "brightness": TestDefaults.brightness, "colorTemp": TestDefaults.color_temp, "hue": TestDefaults.color.hsv.hue * 27.7778, "saturation": TestDefaults.color.hsv.saturation * 100, "value": TestDefaults.color.hsv.value, }, } # class BulbDetails: # details_esl100 = ({ # 'code': 0, # 'msg': None, # 'deviceStatus': 'on', # 'connectionStatus': 'online', # 'name': Defaults.name('ESL100'), # 'brightNess': Defaults.brightness, # 'timer': None, # 'away': None, # 'schedule': None, # 'ownerShip': '1', # 'scheduleCount': 0, # }, 200) # details_esl100cw = { # "light": { # "action": "off", # "brightness": 4, # "colorTempe": 50 # } # } # details_esl100mc = ({ # "traceId": Defaults.trace_id, # "code": 0, # "msg": "request success", # "result": { # "traceId": Defaults.trace_id, # "code": 0, # "result": { # "action": "on", # "brightness": Defaults.brightness, # "colorMode": "color", # "speed": 0, # "red": Defaults.color.rgb.red, # "green": Defaults.color.rgb.green, # "blue": Defaults.color.rgb.blue, # } # } # }, 200) # details_valceno = ( # { # "traceId": TRACE_ID, # "code": 0, # "msg": "request success", # "result": { # "traceId": TRACE_ID, # "code": 0, # "result": { # "enabled": "on", # "colorMode": "color", # "brightness": Defaults.brightness, # "colorTemp": Defaults.color_temp, # "hue": Defaults.color.hsv.hue*27.7778, # "saturation": Defaults.color.hsv.saturation*100, # "value": Defaults.color.hsv.value, # } # } # }, 200 # ) DETAILS_RESPONSES = { 'ESL100': build_bypass_v1_response(result_dict=BULB_DETAILS['ESL100']), 'ESL100CW': build_bypass_v1_response(result_dict=BULB_DETAILS['ESL100CW']), 'ESL100MC': build_bypass_v2_response(inner_result=BULB_DETAILS['ESL100MC']), 'XYD0001': build_bypass_v2_response(inner_result=BULB_DETAILS['XYD0001']), } def valceno_set_status_response(kwargs=None): default_resp = { "traceId": TestDefaults.trace_id, "code": 0, "msg": "request success", "result": { "traceId": TestDefaults.trace_id, "code": 0, "result": { "enabled": "on", "colorMode": "hsv", "brightness": TestDefaults.brightness, "colorTemp": TestDefaults.color_temp, "hue": TestDefaults.color.hsv.hue*27.7778, "saturation": TestDefaults.color.hsv.saturation*100, "value": TestDefaults.color.hsv.value } } } if isinstance(kwargs, dict): if kwargs.get('hue') is not None: default_resp['result']['result']['hue'] = kwargs['hue'] * 27.7778 if kwargs.get('saturation') is not None: default_resp['result']['result']['saturation'] = kwargs['saturation'] * 100 if kwargs.get('value') is not None: default_resp['result']['result']['value'] = kwargs['value'] return default_resp METHOD_RESPONSES = { 'ESL100': deepcopy(FunctionResponsesV1), 'ESL100CW': deepcopy(FunctionResponsesV1), 'ESL100MC': deepcopy(FunctionResponsesV2), 'XYD0001': deepcopy(FunctionResponsesV2), } # METHOD_RESPONSES['XYD0001'].default_factory = lambda: ({ # "traceId": Defaults.trace_id, # "code": 0, # "msg": "request success", # "result": { # "traceId": Defaults.trace_id, # "code": 0 # } # }, 200) # METHOD_RESPONSES['ESL100MC'].default_factory = lambda: ({ # "traceId": Defaults.trace_id, # "code": 0, # "msg": "request success", # "result": { # "traceId": Defaults.trace_id, # "code": 0 # } # }, 200) XYD0001_RESP = { 'set_brightness': valceno_set_status_response, 'set_color_temp': valceno_set_status_response, 'set_hsv': valceno_set_status_response, 'set_rgb': valceno_set_status_response, } METHOD_RESPONSES['XYD0001'].update(XYD0001_RESP) webdjoe-pyvesync-eb8cecb/src/tests/call_json_fans.py000066400000000000000000000071451507433633000231350ustar00rootroot00000000000000""" Air Purifier and Humidifier Device API Responses FANS variable is a list of device types DETAILS_RESPONSES variable is a dictionary of responses from the API for get_details() methods. The keys are the device types and the values are the responses. The responses are tuples of (response, status) METHOD_RESPONSES variable is a defaultdict of responses from the API. This is the FunctionResponse variable from the utils module in the tests dir. The default response is a tuple with the value ({"code": 0, "msg": "success"}, 200). The values of METHOD_RESPONSES can be a function that takes a single argument or a static value. The value is checked if callable at runtime and if so, it is called with the provided argument. If not callable, the value is returned as is. METHOD_RESPONSES = { 'DEV_TYPE': defaultdict( lambda: ({"code": 0, "msg": "success"}, 200)) ) } # For a function to handle the response def status_response(request_body=None): # do work with request_body return request_body, 200 METHOD_RESPONSES['DEV_TYPE']['set_status'] = status_response # To change the default value for a device type METHOD_RESPONSES['DEVTYPE'].default_factory = lambda: ({"code": 0, "msg": "success"}, 200) """ from copy import deepcopy from pyvesync.device_map import fan_modules from defaults import TestDefaults, FunctionResponses, build_bypass_v2_response, FunctionResponsesV2 from pyvesync.const import FanModes FANS = [m.setup_entry for m in fan_modules] FANS_NUM = len(FANS) class FanDefaults: fan_mode = FanModes.NORMAL fan_level = 1 fan_speed_level = 1 temperature_fan = 750 FAN_DETAILS: dict[str, dict] = { "LTF-F422S": { "powerSwitch": TestDefaults.bin_toggle, "workMode": FanModes.NORMAL.value, "manualSpeedLevel": FanDefaults.fan_level, "fanSpeedLevel": FanDefaults.fan_speed_level, "screenState": TestDefaults.bin_toggle, "screenSwitch": TestDefaults.bin_toggle, "oscillationSwitch": TestDefaults.bin_toggle, "oscillationState": TestDefaults.bin_toggle, "muteSwitch": TestDefaults.bin_toggle, "muteState": TestDefaults.bin_toggle, "timerRemain": 0, "temperature": FanDefaults.temperature_fan, "sleepPreference": { "sleepPreferenceType": "default", "oscillationSwitch": TestDefaults.bin_toggle, "initFanSpeedLevel": 0, "fallAsleepRemain": 0, "autoChangeFanLevelSwitch": TestDefaults.bin_toggle, }, "scheduleCount": 0, "displayingType": 0, "errorCode": 0, } } """Contains the result dictionary of the device response details for fans. Dictionary with key being the `setup_entry` attribute for each fan device found in the `pyvesync.device_map.fan_modules` object. The value is the return dictionary for a successful `fan.update()` API request. """ DETAILS_RESPONSES = { "LTF-F422S": build_bypass_v2_response(inner_result=FAN_DETAILS["LTF-F422S"]), } FunctionResponses.default_factory = lambda: ( { "traceId": TestDefaults.trace_id, "code": 0, "msg": "request success", "result": {"traceId": TestDefaults.trace_id, "code": 0}, } ) METHOD_RESPONSES = { "LTF-F422S": deepcopy(FunctionResponsesV2) } """Default responses for device methods. This dictionary maps the setup_entry to the default response for that device. Examples: - For legacy API responses `defaults.FunctionResponses` - For Bypass V1 `defaults.FunctionResponsesV1` - For Bypass V2 `defaults.FunctionResponsesV2` """ # Add responses for methods with different responses than the default webdjoe-pyvesync-eb8cecb/src/tests/call_json_humidifiers.py000066400000000000000000000275261507433633000245230ustar00rootroot00000000000000""" Air Purifier and Humidifier Device API Responses FANS variable is a list of setup_entry's from the device_map DETAILS_RESPONSES variable is a dictionary of responses from the API for get_details() methods. The keys are the device types and the values are the responses. The responses are tuples of (response, status) METHOD_RESPONSES variable is a defaultdict of responses from the API. This is the FunctionResponse variable from the utils module in the tests dir. The default response is a tuple with the value ({"code": 0, "msg": "success"}, 200). The values of METHOD_RESPONSES can be a function that takes a single argument or a static value. The value is checked if callable at runtime and if so, it is called with the provided argument. If not callable, the value is returned as is. METHOD_RESPONSES = { 'DEV_TYPE': defaultdict( lambda: {"code": 0, "msg": "success"} ) } # For a function to handle the response def status_response(request_body=None): # do work with request_body return request_body, 200 METHOD_RESPONSES['DEV_TYPE']['set_status'] = status_response # To change the default value for a device type METHOD_RESPONSES['DEVTYPE'].default_factory = lambda: {"code": 0, "msg": "success"} """ from typing import Any from copy import deepcopy from pyvesync.device_map import humidifier_modules from pyvesync.const import HumidifierModes, DeviceStatus from defaults import TestDefaults, FunctionResponses, build_bypass_v2_response, FunctionResponsesV2 HUMIDIFIERS = [m.setup_entry for m in humidifier_modules] HUMIDIFIERS_NUM = len(HUMIDIFIERS) # FANS = ['Core200S', 'Core300S', 'Core400S', 'Core600S', 'LV-PUR131S', 'LV600S', # 'Classic300S', 'Classic200S', 'Dual200S', 'LV600S'] # def INNER_RESULT(inner: dict) -> dict: # return { # "traceId": Defaults.trace_id, # "code": 0, # "msg": "request success", # "module": None, # "stacktrace": None, # "result": {"traceId": Defaults.trace_id, "code": 0, "result": inner}, # } class HumidifierDefaults: humidifier_mode = HumidifierModes.MANUAL humidity = 50 target_humidity = 60 mist_level = 3 virtual_mist_level = 3 warm_mist_level = 1 warm_mist_enabled = True water_lacks = False water_tank_lifted = False auto_stop = False auto_stop_reached = False display = True display_config = True humidity_high = False HUMIDIFIER_DETAILS: dict[str, Any] = { "Classic300S": { "enabled": True, "humidity": HumidifierDefaults.humidity, "mist_virtual_level": HumidifierDefaults.virtual_mist_level, "mist_level": HumidifierDefaults.mist_level, "mode": HumidifierDefaults.humidifier_mode.value, "water_lacks": HumidifierDefaults.water_lacks, "humidity_high": HumidifierDefaults.humidity_high, "water_tank_lifted": HumidifierDefaults.water_tank_lifted, "display": HumidifierDefaults.display, "automatic_stop_reach_target": HumidifierDefaults.auto_stop_reached, "night_light_brightness": 0, "configuration": { "auto_target_humidity": HumidifierDefaults.target_humidity, "display": HumidifierDefaults.display_config, "automatic_stop": HumidifierDefaults.auto_stop, }, }, "Classic200S": { "enabled": True, "humidity": HumidifierDefaults.humidity, "mist_virtual_level": HumidifierDefaults.virtual_mist_level, "mist_level": HumidifierDefaults.mist_level, "mode": HumidifierDefaults.humidifier_mode.value, "water_lacks": HumidifierDefaults.water_lacks, "humidity_high": HumidifierDefaults.humidity_high, "water_tank_lifted": HumidifierDefaults.water_tank_lifted, "display": HumidifierDefaults.display, "automatic_stop_reach_target": HumidifierDefaults.auto_stop_reached, "night_light_brightness": 0, "configuration": { "auto_target_humidity": HumidifierDefaults.target_humidity, "display": HumidifierDefaults.display_config, "automatic_stop": HumidifierDefaults.auto_stop, }, }, "Dual200S": { "enabled": True, "humidity": HumidifierDefaults.humidity, "mist_virtual_level": HumidifierDefaults.virtual_mist_level, "mist_level": HumidifierDefaults.mist_level, "mode": HumidifierDefaults.humidifier_mode.value, "water_lacks": HumidifierDefaults.water_lacks, "humidity_high": HumidifierDefaults.humidity_high, "water_tank_lifted": HumidifierDefaults.water_tank_lifted, "display": HumidifierDefaults.display, "automatic_stop_reach_target": HumidifierDefaults.auto_stop_reached, "night_light_brightness": 0, "configuration": { "auto_target_humidity": HumidifierDefaults.target_humidity, "display": HumidifierDefaults.display_config, "automatic_stop": HumidifierDefaults.auto_stop, }, }, "LUH-A602S-WUS": { # LV600S "enabled": True, "humidity": HumidifierDefaults.humidity, "mist_virtual_level": HumidifierDefaults.virtual_mist_level, "mist_level": HumidifierDefaults.mist_level, "mode": HumidifierDefaults.humidifier_mode.value, "water_lacks": HumidifierDefaults.water_lacks, "humidity_high": HumidifierDefaults.humidity_high, "water_tank_lifted": HumidifierDefaults.water_tank_lifted, "display": HumidifierDefaults.display, "automatic_stop_reach_target": HumidifierDefaults.auto_stop_reached, "night_light_brightness": 0, "warm_mist_level": HumidifierDefaults.warm_mist_level, "warm_mist_enabled": HumidifierDefaults.warm_mist_enabled, "configuration": { "auto_target_humidity": HumidifierDefaults.target_humidity, "display": HumidifierDefaults.display_config, "automatic_stop": HumidifierDefaults.auto_stop, }, }, "LUH-O451S-WUS": { "enabled": False, "mist_virtual_level": HumidifierDefaults.virtual_mist_level, "mist_level": HumidifierDefaults.mist_level, "mode": HumidifierDefaults.humidifier_mode.value, "water_lacks": HumidifierDefaults.water_lacks, "water_tank_lifted": HumidifierDefaults.water_tank_lifted, "humidity": HumidifierDefaults.humidity, "humidity_high": HumidifierDefaults.humidity_high, "display": HumidifierDefaults.display, "warm_enabled": HumidifierDefaults.warm_mist_enabled, "warm_level": HumidifierDefaults.warm_mist_level, "automatic_stop_reach_target": HumidifierDefaults.auto_stop_reached, "configuration": { "auto_target_humidity": HumidifierDefaults.target_humidity, "display": HumidifierDefaults.display_config, "automatic_stop": HumidifierDefaults.auto_stop, }, "extension": {"schedule_count": 0, "timer_remain": 0}, }, "LUH-O451S-WEU": { "enabled": False, "mist_virtual_level": HumidifierDefaults.virtual_mist_level, "mist_level": HumidifierDefaults.mist_level, "mode": HumidifierDefaults.humidifier_mode.value, "water_lacks": HumidifierDefaults.water_lacks, "water_tank_lifted": HumidifierDefaults.water_tank_lifted, "humidity": HumidifierDefaults.humidity, "humidity_high": HumidifierDefaults.humidity_high, "display": HumidifierDefaults.display, "warm_enabled": HumidifierDefaults.warm_mist_enabled, "warm_level": HumidifierDefaults.warm_mist_level, "automatic_stop_reach_target": HumidifierDefaults.auto_stop_reached, "configuration": { "auto_target_humidity": HumidifierDefaults.target_humidity, "display": HumidifierDefaults.display_config, "automatic_stop": HumidifierDefaults.auto_stop, }, "extension": {"schedule_count": 0, "timer_remain": 0}, }, "LUH-M101S": { "powerSwitch": int(DeviceStatus.ON), "humidity": HumidifierDefaults.humidity, "targetHumidity": HumidifierDefaults.target_humidity, "virtualLevel": HumidifierDefaults.virtual_mist_level, "mistLevel": HumidifierDefaults.mist_level, "workMode": HumidifierDefaults.humidifier_mode.value, "waterLacksState": int(HumidifierDefaults.water_lacks), "waterTankLifted": int(HumidifierDefaults.water_tank_lifted), "autoStopSwitch": int(HumidifierDefaults.auto_stop), "autoStopState": int(HumidifierDefaults.auto_stop_reached), "screenSwitch": int(HumidifierDefaults.display_config), "screenState": int(HumidifierDefaults.display), "deviceProp": { "workMode": "auto", "nightLight": { "nightLightSwitch": 0, "brightness": 97 }, }, "scheduleCount": 0, "timerRemain": 0, "errorCode": 0, }, "LEH-S601S": { "powerSwitch": int(DeviceStatus.ON), "humidity": HumidifierDefaults.humidity, "targetHumidity": HumidifierDefaults.target_humidity, "virtualLevel": HumidifierDefaults.virtual_mist_level, "mistLevel": HumidifierDefaults.mist_level, "workMode": HumidifierDefaults.humidifier_mode.value, "waterLacksState": int(HumidifierDefaults.water_lacks), "waterTankLifted": int(HumidifierDefaults.water_tank_lifted), "autoStopSwitch": int(HumidifierDefaults.auto_stop), "autoStopState": int(HumidifierDefaults.auto_stop_reached), "screenSwitch": int(HumidifierDefaults.display_config), "screenState": int(HumidifierDefaults.display), "scheduleCount": 0, "timerRemain": 0, "errorCode": 0, "dryingMode": { "dryingLevel": 1, "autoDryingSwitch": 1, "dryingState": 2, "dryingRemain": 7200, }, "autoPreference": 1, "childLockSwitch": 0, "filterLifePercent": 93, "temperature": 662, }, } """This dictionary contains the details response for each humidifier. It stores the innermost result that is passed to the DETAILS_RESPONSE variable where the full API response is built.""" DETAILS_RESPONSES = { "Classic300S": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["Classic300S"]), "Classic200S": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["Classic200S"]), "Dual200S": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["Dual200S"]), "LUH-A602S-WUS": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["LUH-A602S-WUS"]), "LUH-O451S-WUS": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["LUH-O451S-WUS"]), "LUH-O451S-WEU": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["LUH-O451S-WEU"]), "LUH-M101S": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["LUH-M101S"]), "LEH-S601S": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["LEH-S601S"]), } FunctionResponses.default_factory = lambda: ( { "traceId": TestDefaults.trace_id, "code": 0, "msg": "request success", "result": {"traceId": TestDefaults.trace_id, "code": 0}, }, 200, ) METHOD_RESPONSES = { "Classic300S": deepcopy(FunctionResponsesV2), "Classic200S": deepcopy(FunctionResponsesV2), "Dual200S": deepcopy(FunctionResponsesV2), "LUH-A602S-WUS": deepcopy(FunctionResponsesV2), "LUH-O451S-WUS": deepcopy(FunctionResponsesV2), "LUH-O451S-WEU": deepcopy(FunctionResponsesV2), "LUH-M101S": deepcopy(FunctionResponsesV2), "LEH-S601S": deepcopy(FunctionResponsesV2), } # Add responses for methods with different responses than the default # Timer Responses # for k in AIR_MODELS: # METHOD_RESPONSES[k]["set_timer"] = (INNER_RESULT({"id": 1}), 200) # METHOD_RESPONSES[k]["get_timer"] = ( # INNER_RESULT({"id": 1, "remain": 100, "total": 100, "action": "off"}), # 200, # ) # FAN_TIMER = helpers.Timer(100, "off") webdjoe-pyvesync-eb8cecb/src/tests/call_json_outlets.py000066400000000000000000000163151507433633000237040ustar00rootroot00000000000000""" Outlet Device API Responses OUTLET variable is a list of device types DETAILS_RESPONSES variable is a dictionary of responses from the API for get_details() methods. The keys are the device types and the values are the responses. The responses are tuples of (response, status) METHOD_RESPONSES variable is a defaultdict of responses from the API. This is the FunctionResponse variable from the utils module in the tests dir. The default response is a tuple with the value ({"code": 0, "msg": "success"}, 200). The values of METHOD_RESPONSES can be a function that takes a single argument or a static value. The value is checked if callable at runtime and if so, it is called with the provided argument. If not callable, the value is returned as is. METHOD_RESPONSES = { 'DEV_TYPE': defaultdict( lambda: ({"code": 0, "msg": "success"}, 200)) ) } ### For a function to handle the response def status_response(request_kwargs=None): # do work with request_kwargs return request_body, 200 METHOD_RESPONSES['DEV_TYPE']['set_status'] = status_response ### To change the default value for a device type METHOD_RESPONSES['DEVTYPE'].default_factory = lambda: ({"code": 0, "msg": "success"}, 200) If changing default response for all devices, change the default factory of the import default dict but make sure to use `deepcopy` to avoid unintended side effects. """ from copy import deepcopy from collections import defaultdict from pyvesync.const import ( DeviceStatus, ConnectionStatus, NightlightModes, NightlightStatus, ) from pyvesync.device_map import outlet_modules from defaults import ( FunctionResponses, TestDefaults, build_bypass_v1_response, build_bypass_v2_response, FunctionResponsesV1, FunctionResponsesV2, ) OUTLETS = [m.setup_entry for m in outlet_modules] OUTLETS_NUM = len(OUTLETS) class OutletDefaults: device_status = DeviceStatus.ON connection_status = ConnectionStatus.ONLINE night_light_status = NightlightStatus.ON night_light_mode = NightlightModes.AUTO voltage = 120 # volts energy = 10 # kilowatt power = 20 # kilowatt-hours round_7a_voltage = "78000:78000" # 120 Volts round_7a_power = "1000:1000" # 1 watt OUTLET_DETAILS: dict[str, dict] = { "wifi-switch-1.3": { "deviceStatus": OutletDefaults.device_status.value, "deviceImg": "https://image.vesync.com/defaultImages/deviceDefaultImages/7aoutlet_240.png", "energy": OutletDefaults.energy, "activeTime": TestDefaults.active_time, "power": OutletDefaults.round_7a_power, "voltage": OutletDefaults.round_7a_voltage, }, "ESW03-USA": { "code": 0, "msg": None, "deviceStatus": OutletDefaults.device_status.value, "connectionStatus": OutletDefaults.connection_status.value, "activeTime": TestDefaults.active_time, "energy": OutletDefaults.energy, "nightLightStatus": None, "nightLightBrightness": None, "nightLightAutomode": None, "power": OutletDefaults.power, "voltage": OutletDefaults.voltage, }, "ESW01-EU": { "code": 0, "msg": None, "deviceStatus": OutletDefaults.device_status.value, "connectionStatus": OutletDefaults.connection_status.value, "activeTime": TestDefaults.active_time, "energy": OutletDefaults.energy, "nightLightStatus": None, "nightLightBrightness": None, "nightLightAutomode": None, "power": OutletDefaults.power, "voltage": OutletDefaults.voltage, }, "ESW15-USA": { # V1 "activeTime": TestDefaults.active_time, "deviceName": "Etekcity 15A WiFi Outlet US/CA", "deviceStatus": OutletDefaults.device_status.value, "power": OutletDefaults.power, "voltage": OutletDefaults.voltage, "energy": OutletDefaults.energy, "nightLightStatus": OutletDefaults.night_light_status.value, "nightLightAutomode": OutletDefaults.night_light_mode.value, "nightLightBrightness": 50, "deviceImg": "https://image.vesync.com/defaultImages/deviceDefaultImages/15aoutletnightlight_240.png", "connectionStatus": OutletDefaults.connection_status.value, }, "ESO15-TB": { "activeTime": TestDefaults.active_time, "deviceName": "Etekcity Outdoor Plug", "deviceStatus": OutletDefaults.device_status.value, "power": OutletDefaults.power, "voltage": OutletDefaults.voltage, "energy": OutletDefaults.energy, "subDevices": [ { "subDeviceNo": 1, "defaultName": "Socket A", "subDeviceName": "Socket A", "subDeviceImg": "", "subDeviceStatus": OutletDefaults.device_status.value, }, { "subDeviceNo": 2, "defaultName": "Socket B", "subDeviceName": "Socket B", "subDeviceImg": "", "subDeviceStatus": OutletDefaults.device_status.value, }, ], "deviceImg": "", "connectionStatus": OutletDefaults.connection_status.value, }, "BSDOG01": { "powerSwitch_1": int(OutletDefaults.device_status), "active_time": TestDefaults.active_time, "connectionStatus": OutletDefaults.connection_status.value, "code": 0, }, } DETAILS_RESPONSES = { "wifi-switch-1.3": OUTLET_DETAILS["wifi-switch-1.3"], "ESW03-USA": OUTLET_DETAILS["ESW03-USA"], "ESW01-EU": OUTLET_DETAILS["ESW01-EU"], "ESW15-USA": build_bypass_v1_response(result_dict=OUTLET_DETAILS["ESW15-USA"]), "ESO15-TB": build_bypass_v1_response(result_dict=OUTLET_DETAILS["ESO15-TB"]), "BSDOG01": build_bypass_v2_response(inner_result=OUTLET_DETAILS["BSDOG01"]), } ENERGY_HISTORY = { "traceId": TestDefaults.trace_id, "code": 0, "msg": "request success", "module": None, "stacktrace": None, "result": { "energyConsumptionOfToday": 1, "costPerKWH": 0.50, "maxEnergy": 0.7, "totalEnergy": 2.0, "totalMoney": 1.0, "averageEnergy": 0.01, "averageMoney": 0.0, "maxCost": 35.0, "energySavingStatus": "off", "currency": "USD", "energyInfos": [ {"timestamp": 1742184000025, "energyKWH": 0.0015, "money": 0.0}, {"timestamp": 1742270400025, "energyKWH": 0.0016, "money": 0.0}, {"timestamp": 1742356800025, "energyKWH": 0.0017, "money": 0.0}, ], }, } METHOD_RESPONSES = { "wifi-switch-1.3": defaultdict(lambda: None), "ESW03-USA": deepcopy(FunctionResponses), "ESW01-EU": deepcopy(FunctionResponses), "ESW15-USA": deepcopy(FunctionResponsesV1), "ESO15-TB": deepcopy(FunctionResponsesV1), "BSDOG01": deepcopy(FunctionResponsesV2), } for k in METHOD_RESPONSES: METHOD_RESPONSES[k]["get_weekly_energy"] = ENERGY_HISTORY METHOD_RESPONSES[k]["get_monthly_energy"] = ENERGY_HISTORY METHOD_RESPONSES[k]["get_yearly_energy"] = ENERGY_HISTORY # # Add BSDGO1 specific responses # METHOD_RESPONSES['BSDOG01'] = defaultdict(lambda: ({ # "code": 0, # "msg": "request success", # "result": { # "traceId": Defaults.trace_id, # "code": 0 # } # }, 200)) webdjoe-pyvesync-eb8cecb/src/tests/call_json_purifiers.py000066400000000000000000000337551507433633000242240ustar00rootroot00000000000000""" Air Purifier Device API Responses FANS variable is a list of device types DETAILS_RESPONSES variable is a dictionary of responses from the API for get_details() methods. The keys are the device types and the values are the responses. The responses are tuples of (response, status) METHOD_RESPONSES variable is a defaultdict of responses from the API. This is the FunctionResponse variable from the utils module in the tests dir. The default response is a tuple with the value ({"code": 0, "msg": "success"}, 200). The values of METHOD_RESPONSES can be a function that takes a single argument or a static value. The value is checked if callable at runtime and if so, it is called with the provided argument. If not callable, the value is returned as is. METHOD_RESPONSES = { 'DEV_TYPE': defaultdict( lambda: ({"code": 0, "msg": "success"}, 200)) ) } # For a function to handle the response def status_response(request_body=None): # do work with request_body return request_body, 200 METHOD_RESPONSES['DEV_TYPE']['set_status'] = status_response # To change the default value for a device type METHOD_RESPONSES['DEVTYPE'].default_factory = lambda: ({"code": 0, "msg": "success"}, 200) """ from copy import deepcopy from typing import Any from pyvesync.device_map import purifier_modules from pyvesync.const import ConnectionStatus, PurifierModes, DeviceStatus, AirQualityLevel from defaults import ( TestDefaults, FunctionResponses, build_bypass_v2_response, build_bypass_v1_response, FunctionResponsesV1, FunctionResponsesV2, ) PURIFIER_MODELS = [m.setup_entry for m in purifier_modules] # FANS = ['Core200S', 'Core300S', 'Core400S', 'Core600S', 'LV-PUR131S', 'LV600S', # 'Classic300S', 'Classic200S', 'Dual200S', 'LV600S'] PURIFIERS_NUM = len(PURIFIER_MODELS) class PurifierDefaults: purifier_mode = PurifierModes.MANUAL.value air_quality_enum = AirQualityLevel.GOOD fan_level = 1 fan_set_level = 2 filter_life = 80 humidity = 50 night_light = DeviceStatus.ON display = DeviceStatus.ON display_config = DeviceStatus.ON display_forever = True light_detection = False light_detected = True child_lock = DeviceStatus.ON air_quality_level = 1 filter_open = 0 aq_percent = 75 air_quality_value_pm25 = 3 pm1 = 10 pm10 = 5 rotate_angle = 45 voc = 120 co2 = 669 temperature = 791 PURIFIER_DETAILS: dict[str, dict[str, Any]] = { "Core200S": { "enabled": True, "filter_life": PurifierDefaults.filter_life, "mode": PurifierDefaults.purifier_mode, "level": PurifierDefaults.fan_level, "display": bool(PurifierDefaults.display), "child_lock": bool(PurifierDefaults.child_lock), "night_light": PurifierDefaults.night_light.value, "configuration": { "display": bool(PurifierDefaults.display), "display_forever": PurifierDefaults.display_forever }, "device_error_code": 0 }, "Core300S": { "enabled": True, "filter_life": PurifierDefaults.filter_life, "mode": PurifierDefaults.purifier_mode, "level": PurifierDefaults.fan_level, "air_quality": PurifierDefaults.air_quality_enum.value, "air_quality_value": PurifierDefaults.air_quality_value_pm25, "display": bool(PurifierDefaults.display), "child_lock": bool(PurifierDefaults.child_lock), "configuration": { "display": bool(PurifierDefaults.display_config), "display_forever": PurifierDefaults.display_forever, "auto_preference": {"type": "default", "room_size": 0}, }, "extension": {"schedule_count": 0, "timer_remain": 0}, "device_error_code": 0, }, "Core400S": { "enabled": True, "filter_life": PurifierDefaults.filter_life, "mode": PurifierDefaults.purifier_mode, "level": PurifierDefaults.fan_level, "air_quality": PurifierDefaults.air_quality_enum.value, "air_quality_value": PurifierDefaults.air_quality_value_pm25, "display": bool(PurifierDefaults.display), "child_lock": bool(PurifierDefaults.child_lock), "configuration": { "display": bool(PurifierDefaults.display_config), "display_forever": PurifierDefaults.display_forever, "auto_preference": {"type": "default", "room_size": 0}, }, "extension": {"schedule_count": 0, "timer_remain": 0}, "device_error_code": 0, }, "Core600S": { "enabled": True, "filter_life": PurifierDefaults.filter_life, "mode": PurifierDefaults.purifier_mode, "level": PurifierDefaults.fan_level, "air_quality": PurifierDefaults.air_quality_enum.value, "air_quality_value": PurifierDefaults.air_quality_value_pm25, "display": bool(PurifierDefaults.display), "child_lock": bool(PurifierDefaults.child_lock), "configuration": { "display": bool(PurifierDefaults.display_config), "display_forever": PurifierDefaults.display_forever, "auto_preference": {"type": "default", "room_size": 0}, }, "extension": {"schedule_count": 0, "timer_remain": 0}, "device_error_code": 0, }, "LV-PUR131S": { "screenStatus": PurifierDefaults.display.value, "filterLife": { "change": False, "useHour": 3520, "percent": PurifierDefaults.filter_life, }, "activeTime": TestDefaults.active_time, "timer": None, "scheduleCount": 0, "schedule": None, "levelNew": PurifierDefaults.fan_set_level, "airQuality": str(PurifierDefaults.air_quality_enum), "level": PurifierDefaults.fan_level, "mode": PurifierDefaults.purifier_mode, "deviceName": "Levoit 131S Air Purifier", "currentFirmVersion": "2.0.58", "childLock": PurifierDefaults.child_lock.value, "deviceStatus": DeviceStatus.ON.value, "deviceImg": "https://image.vesync.com/defaultImages/deviceDefaultImages/airpurifier131_240.png", "connectionStatus": ConnectionStatus.ONLINE.value, }, "LAP-V102S": { # Vital 100S "powerSwitch": int(DeviceStatus.ON), "filterLifePercent": PurifierDefaults.filter_life, "workMode": PurifierDefaults.purifier_mode, "manualSpeedLevel": PurifierDefaults.fan_set_level, "fanSpeedLevel": PurifierDefaults.fan_level, "AQLevel": int(PurifierDefaults.air_quality_enum), "PM25": PurifierDefaults.air_quality_value_pm25, "screenState": int(PurifierDefaults.display), "childLockSwitch": int(PurifierDefaults.child_lock), "screenSwitch": int(PurifierDefaults.display_config), "lightDetectionSwitch": int(PurifierDefaults.light_detection), "environmentLightState": int(PurifierDefaults.light_detected), "autoPreference": {"autoPreferenceType": "default", "roomSize": 600}, "scheduleCount": 0, "timerRemain": 0, "efficientModeTimeRemain": 0, "errorCode": 0, }, "LAP-V201S": { # Vital 200S "powerSwitch": int(DeviceStatus.ON), "filterLifePercent": PurifierDefaults.filter_life, "workMode": PurifierDefaults.purifier_mode, "manualSpeedLevel": PurifierDefaults.fan_set_level, "fanSpeedLevel": PurifierDefaults.fan_level, "AQLevel": PurifierDefaults.air_quality_enum.value, "PM25": PurifierDefaults.air_quality_value_pm25, "screenState": PurifierDefaults.display, "childLockSwitch": PurifierDefaults.child_lock, "screenSwitch": PurifierDefaults.display_config, "lightDetectionSwitch": PurifierDefaults.light_detection, "environmentLightState": PurifierDefaults.light_detected, "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, }, "EL551S": { # Everest Air "fanRotateAngle": PurifierDefaults.rotate_angle, "filterOpenState": PurifierDefaults.filter_open, "powerSwitch": int(DeviceStatus.ON), "filterLifePercent": PurifierDefaults.filter_life, "workMode": PurifierDefaults.purifier_mode, "manualSpeedLevel": PurifierDefaults.fan_set_level, "fanSpeedLevel": PurifierDefaults.fan_level, "AQLevel": PurifierDefaults.air_quality_enum.value, "AQPercent": PurifierDefaults.aq_percent, "PM25": PurifierDefaults.air_quality_value_pm25, "PM1": PurifierDefaults.pm1, "PM10": PurifierDefaults.pm10, "screenState": int(PurifierDefaults.display), "childLockSwitch": int(PurifierDefaults.child_lock), "screenSwitch": int(PurifierDefaults.display_config), "lightDetectionSwitch": int(PurifierDefaults.light_detection), "environmentLightState": int(PurifierDefaults.light_detected), "autoPreference": {"autoPreferenceType": "default", "roomSize": 0}, "routine": {"routineType": "normal", "runSeconds": 0}, "scheduleCount": 0, "timerRemain": 0, "efficientModeTimeRemain": 0, "ecoModeRunTime": 0, "errorCode": 0, }, "LAP-B851S-WUS": { "traceId": TestDefaults.trace_id, "code": 0, "msg": "request success", "module": None, "stacktrace": None, "result": { "traceId": TestDefaults.trace_id, "code": 0, "result": { "powerSwitch": TestDefaults.bin_toggle, "workMode": "auto", "manualSpeedLevel": PurifierDefaults.fan_level, "fanSpeedLevel": PurifierDefaults.fan_set_level, "PM25": PurifierDefaults.air_quality_value_pm25, "PM1": PurifierDefaults.pm1, "PM10": PurifierDefaults.pm10, "screenState": int(PurifierDefaults.display), "childLockSwitch": int(PurifierDefaults.child_lock), "screenSwitch": int(PurifierDefaults.display_config), "lampType": 0, "roomSize": 242, "lampSwitch": int(PurifierDefaults.display), "autoPreference": {"autoPreferenceType": "default", "roomSize": 630}, "scheduleCount": 0, "timerRemain": 0, "efficientModeTimeRemain": 1182, "humidity": PurifierDefaults.humidity, "AQI": PurifierDefaults.aq_percent, "AQLevel": PurifierDefaults.air_quality_level, "VOC": PurifierDefaults.voc, "CO2": PurifierDefaults.co2, "temperature": PurifierDefaults.temperature, "nightLight": { "nightLightSwitch": int(PurifierDefaults.night_light), "brightness": TestDefaults.brightness, "colorTemperature": TestDefaults.color_temp_k, }, "breathingLamp": { "breathingLampSwitch": TestDefaults.bin_toggle, "colorTemperature": TestDefaults.color_temp_k, "timeInterval": 5, "brightnessStart": 10, "brightnessEnd": 90, }, "errorCode": 0, "dumpedState": 0, "whiteNoiseInfo": { "playStatus": 0, "soundId": 100006, "countDown": 1800, "countingDown": 1800, "downloadStatus": 2, }, "guardingInfo": {"guarding": 0, "remainTS": 100}, }, }, }, } DETAILS_RESPONSES = { "LV-PUR131S": build_bypass_v1_response(result_dict=PURIFIER_DETAILS["LV-PUR131S"]), "Core200S": build_bypass_v2_response(inner_result=PURIFIER_DETAILS["Core200S"]), "Core300S": build_bypass_v2_response(inner_result=PURIFIER_DETAILS["Core300S"]), "Core400S": build_bypass_v2_response(inner_result=PURIFIER_DETAILS["Core400S"]), "Core600S": build_bypass_v2_response(inner_result=PURIFIER_DETAILS["Core600S"]), "LAP-V102S": build_bypass_v2_response(inner_result=PURIFIER_DETAILS["LAP-V102S"]), "LAP-V201S": build_bypass_v2_response(inner_result=PURIFIER_DETAILS["LAP-V201S"]), "EL551S": build_bypass_v2_response(inner_result=PURIFIER_DETAILS["EL551S"]), "LAP-B851S-WUS": build_bypass_v2_response(inner_result=PURIFIER_DETAILS["LAP-B851S-WUS"]), } FunctionResponses.default_factory = lambda: ( { "traceId": TestDefaults.trace_id, "code": 0, "msg": "request success", "result": {"traceId": TestDefaults.trace_id, "code": 0}, }, 200, ) METHOD_RESPONSES = { "LV-PUR131S": deepcopy(FunctionResponsesV1), "Core200S": deepcopy(FunctionResponsesV2), "Core300S": deepcopy(FunctionResponsesV2), "Core400S": deepcopy(FunctionResponsesV2), "Core600S": deepcopy(FunctionResponsesV2), "LAP-V102S": deepcopy(FunctionResponsesV2), "LAP-V201S": deepcopy(FunctionResponsesV2), "EL551S": deepcopy(FunctionResponsesV2), "LAP-B851S-WUS": deepcopy(FunctionResponsesV2), } # Add responses for methods with different responses than the default # # Timer Responses # for k in AIR_MODELS: # METHOD_RESPONSES[k]["set_timer"] = (INNER_RESULT({"id": 1}), 200) # METHOD_RESPONSES[k]["get_timer"] = ( # INNER_RESULT({"id": 1, "remain": 100, "total": 100, "action": "off"}), # 200, # ) # FAN_TIMER = helpers.Timer(100, "off") webdjoe-pyvesync-eb8cecb/src/tests/call_json_switches.py000066400000000000000000000077241507433633000240420ustar00rootroot00000000000000""" Switch Device API Responses SWITCHES variable is a list of device types DETAILS_RESPONSES variable is a dictionary of responses from the API for get_details() methods. The keys are the device types and the values are the responses. The responses are tuples of (response, status) METHOD_RESPONSES variable is a defaultdict of responses from the API. This is the FunctionResponse variable from the utils module in the tests dir. The default response is a tuple with the value ({"code": 0, "msg": "success"}, 200). """ from copy import deepcopy # from pyvesync.devices import vesyncswitch from pyvesync.device_map import switch_modules from pyvesync.const import DeviceStatus, ConnectionStatus from defaults import TestDefaults, FunctionResponsesV1, build_bypass_v1_response SWITCHES = [m.setup_entry for m in switch_modules] SWITCHES_NUM = len(SWITCHES) class SwitchDefaults: device_status = DeviceStatus.ON connection_status = ConnectionStatus.ONLINE indicator_status = DeviceStatus.ON rgb_status = DeviceStatus.ON SWITCH_DETAILS: dict[str, dict] = { "ESWL01": { # V1 "deviceStatus": SwitchDefaults.device_status.value, "activeTime": TestDefaults.active_time, "deviceName": "Etekcity Light Switch", "deviceImg": "", "connectionStatus": SwitchDefaults.connection_status.value, }, "ESWL03": { # V1 "deviceStatus": SwitchDefaults.device_status.value, "activeTime": TestDefaults.active_time, "deviceName": "Etekcity Light Switch", "deviceImg": "", "connectionStatus": SwitchDefaults.connection_status.value, }, "ESWD16": { "deviceStatus": SwitchDefaults.device_status.value, "activeTime": TestDefaults.active_time, "devicename": "Etekcity Dimmer Switch", "indicatorlightStatus": SwitchDefaults.indicator_status.value, "startMode": None, "brightness": TestDefaults.brightness, "rgbStatus": SwitchDefaults.rgb_status.value, "rgbValue": TestDefaults.color.rgb.to_dict(), "timer": None, "schedule": None, "deviceImg": "https://image.vesync.com/defaultImages/deviceDefaultImages/wifiwalldimmer_240.png", "connectionStatus": SwitchDefaults.connection_status.value, }, } # class SwitchDetails: # details_ws = ( # { # 'code': 0, # 'msg': None, # 'deviceStatus': 'on', # 'connectionStatus': 'online', # 'activeTime': Defaults.active_time, # 'power': 'None', # 'voltage': 'None', # }, # 200, # ) # details_eswd16 = ({ # "code": 0, # "msg": "请求成功", # "traceId": Defaults.trace_id, # "indicatorlightStatus": "on", # "timer": None, # "schedule": None, # "brightness": "100", # "startMode": None, # "activeTime": Defaults.active_time, # "rgbStatus": "on", # "rgbValue": { # "red": Defaults.color.rgb.red, # "blue": Defaults.color.rgb.blue, # "green": Defaults.color.rgb.green # }, # "connectionStatus": "online", # "devicename": Defaults.name('ESWD16'), # "deviceStatus": "on" # }, 200) DETAILS_RESPONSES = { 'ESWL01': build_bypass_v1_response(result_dict=SWITCH_DETAILS['ESWL01']), 'ESWD16': build_bypass_v1_response(result_dict=SWITCH_DETAILS['ESWD16']), 'ESWL03': build_bypass_v1_response(result_dict=SWITCH_DETAILS['ESWL03']), } METHOD_RESPONSES = { 'ESWL01': deepcopy(FunctionResponsesV1), 'ESWD16': deepcopy(FunctionResponsesV1), 'ESWL03': deepcopy(FunctionResponsesV1), } webdjoe-pyvesync-eb8cecb/src/tests/call_json_thermostat.py000066400000000000000000000063441507433633000244000ustar00rootroot00000000000000""" Thermostat Device API Responses THERMOSTATS variable is a list of setup_entry's from the device_map DETAILS_RESPONSES variable is a dictionary of responses from the API for get_details() methods. The keys are the device types and the values are the responses. The responses are tuples of (response, status) METHOD_RESPONSES variable is a defaultdict of responses from the API. This is the FunctionResponse variable from the utils module in the tests dir. The default response is a tuple with the value ({"code": 0, "msg": "success"}, 200). The values of METHOD_RESPONSES can be a function that takes a single argument or a static value. The value is checked if callable at runtime and if so, it is called with the provided argument. If not callable, the value is returned as is. METHOD_RESPONSES = { 'DEV_TYPE': defaultdict( lambda: {"code": 0, "msg": "success"} ) } # For a function to handle the response def status_response(request_body=None): # do work with request_body return request_body, 200 METHOD_RESPONSES['DEV_TYPE']['set_status'] = status_response # To change the default value for a device type METHOD_RESPONSES['DEVTYPE'].default_factory = lambda: {"code": 0, "msg": "success"} """ from copy import deepcopy from pyvesync.device_map import thermostat_modules from pyvesync.const import ThermostatConst from defaults import ( build_bypass_v2_response, FunctionResponsesV2, ) THERMOSTATS = [m.setup_entry for m in thermostat_modules] THERMOSTATS_NUM = len(THERMOSTATS) class ThermostatDefaults: current_temp = 65 target_temp = 75 humidity = 45 work_mode = ThermostatConst.WorkMode.HEAT work_status = ThermostatConst.WorkStatus.HEATING eco_mode = ThermostatConst.EcoType.BALANCE temp_unit = "f" schedule_enabled = "on" away_mode = "off" child_lock = "off" screen_display = "on" outdoor_temp = 600 outdoor_humidity = 300 filter_life = 100 battery_level = 100 hvac_state = "idle" compressor_state = "off" fan_state = "off" fan_speed = "auto" fan_speed_level = 1 swing_mode = "off" aux_heat_state = "off" THERMOSTAT_DETAILS: dict[str, dict] = { "LTM-A401S-WUS": { "supportMode": [0, 1, 2, 3, 5], "workMode": 3, "workStatus": 2, "fanMode": 1, "fanStatus": 1, "routineRunningId": 2, "tempUnit": "f", "temperature": 75.570000, "humidity": 56, "heatToTemp": 60, "coolToTemp": 70, "lockStatus": False, "scheduleOrHold": 2, "holdEndTime": 1696033274, "holdOption": 5, "deadband": 4, "safetyTempLow": "40.00", "safetyTempHigh": "100.00", "ecoType": 3, "alertStatus": 2, "routines": [ {"name": "Home", "routineId": 2}, {"name": "Away", "routineId": 1}, {"name": "Sleep", "routineId": 3}, ], }, } """Dictionary with setup_entry as the key and the value being the result object of the device response from the get_details API for thermostats.""" DEVICE_DETAILS = { "LTM-A401S-WUS": build_bypass_v2_response(inner_result=THERMOSTAT_DETAILS["LTM-A401S-WUS"]), } METHOD_RESPONSES = { "LTM-A401S-WUS": deepcopy(FunctionResponsesV2) } webdjoe-pyvesync-eb8cecb/src/tests/conftest.py000066400000000000000000000133521507433633000220040ustar00rootroot00000000000000"""pytest test parametrization for VeSync devices.""" import os import sys import pytest 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): """Generate tests for device methods. Excludes legacy tests that start with 'test_x'. """ if metafunc.cls is None or 'test_x' in metafunc.module.__name__: return write_api = bool(metafunc.config.getoption('--write_api')) overwrite = bool(metafunc.config.getoption('--overwrite')) metafunc.cls.overwrite = overwrite metafunc.cls.write_api = write_api # Require class attribute 'device' to parametrize tests if 'device' in metafunc.cls.__dict__: device = metafunc.cls.__dict__['device'] if device not in metafunc.cls.__dict__: return devices = metafunc.cls.__dict__[device] if metafunc.function.__name__ == 'test_details': return details_generator(metafunc, device, devices) if metafunc.function.__name__ == 'test_methods': return method_generator(metafunc, device, devices) def details_generator(metafunc, gen_type, devices): """Parametrize device tests for get_details().""" id_list = [] argvalues = [] for setup_entry in devices: id_list.append(f"{gen_type}.{setup_entry}.update") argvalues.append([setup_entry, 'update']) metafunc.parametrize("setup_entry, method", argvalues, ids=id_list) def method_generator(metafunc, gen_type, devices): """Parametrize device tests for methods.""" if 'base_methods' in metafunc.cls.__dict__: base_methods = metafunc.cls.__dict__['base_methods'] else: base_methods = [] call_dict = {dt: base_methods.copy() for dt in devices} if 'device_methods' in metafunc.cls.__dict__: dev_methods = metafunc.cls.__dict__['device_methods'] for dev in call_dict: if dev in dev_methods: call_dict[dev].extend(dev_methods[dev]) id_list = [] argvalues = [] for dev, methods in call_dict.items(): for method in methods: id_list.append(f'{gen_type}.{dev}.{method}') argvalues.append([dev, method]) metafunc.parametrize("setup_entry, method", argvalues, ids=id_list) return def _is_interactive(config: pytest.Config) -> bool: """Best-effort check for an interactive terminal.""" tr = config.pluginmanager.getplugin("terminalreporter") if tr and getattr(tr, "isatty", False): return True for stream in (sys.__stdin__, sys.__stdout__, sys.__stderr__): try: if stream and stream.isatty(): return True except Exception: pass return False def _prompt_confirm(config: pytest.Config) -> bool: """Suspend capture, show a prompt, and return True if user confirmed.""" flags = [] if config.getoption("--write_api"): flags.append("--write_api") if config.getoption("--overwrite"): flags.append("--overwrite") if len(flags) == 0: return True if '--overwrite' in flags and '--write_api' not in flags: # Overwrite requires write_api to be set pytest.exit( "\nBoth --overwrite and --write_api need to be set to overwrite API calls.", 1 ) # Interactive prompt if "--write_api" in flags and "--overwrite" not in flags: msg = ( "\nAre you sure you want to write API requests to files?\n" "Continue? [y/N]: " ) elif "--overwrite" in flags and "--write_api" in flags: msg = ( "\nAre you sure you want to OVERWRITE existing API requests?\n" "Continue? [y/N]: " ) else: pytest.exit("\nUnrecognized combination of flags: " + ", ".join(flags), 1) if not flags: return True capman = config.pluginmanager.getplugin("capturemanager") tr = config.pluginmanager.getplugin("terminalreporter") if capman: # allow stdin/stdout to pass through for input() capman.suspend_global_capture(in_=True) # type: ignore[arg-type] try: if tr and getattr(tr, "isatty", False): tr._tw.write(msg) # type: ignore[arg-type] else: print(msg, end="", flush=True) try: resp = input().strip().lower() except EOFError: resp = "" return resp in {"y", "yes"} finally: if capman: capman.resume_global_capture() # type: ignore[arg-type] def _require_confirmation(config: pytest.Config) -> None: """Prompt once per session if any destructive flags are set.""" if not config.getoption("--write_api") and not config.getoption("--overwrite"): return # Allow opt-out via flag or env var in CI if str(os.environ.get("PYTEST_CONFIRM", "")).lower() in {"1", "true", "yes", "y"}: return # If there's no TTY, force explicit opt-out instead of hanging if not _is_interactive(config): raise pytest.UsageError( "write_api require confirmation, but the session is non-interactive. " "Re-run with environment variable set PYTEST_CONFIRM=1." ) if not _prompt_confirm(config): pytest.exit("Aborted by user.", returncode=1) def pytest_sessionstart(session: pytest.Session) -> None: # Runs before collection; safe place to ask once and abort if needed _require_confirmation(session.config) webdjoe-pyvesync-eb8cecb/src/tests/defaults.py000066400000000000000000000177221507433633000217730ustar00rootroot00000000000000"""Common base class for tests and Default values. Routine Listings ---------------- FunctionResponses : defaultdict Defaultdict of the standard response tuple for device methods Defaults: class Default values and methods for generating default values """ from typing import TypeVar, Any from collections import defaultdict from requests.structures import CaseInsensitiveDict import pyvesync.const as const from pyvesync.utils.helpers import Converters from pyvesync.device_map import DeviceMapTemplate from pyvesync.utils.colors import Color, RGB T = TypeVar("T", bound=DeviceMapTemplate) FunctionResponses: defaultdict = defaultdict(lambda: {"code": 0, "msg": None}) FunctionResponsesV1: defaultdict = defaultdict( lambda: {"traceId": TestDefaults.trace_id, "code": 0, "msg": None, "result": {}} ) FunctionResponsesV2: defaultdict = defaultdict( lambda: { "traceId": TestDefaults.trace_id, "code": 0, "msg": None, "result": {"code": 0, "msg": None, "traceId": TestDefaults.trace_id, "result": {}}, } ) CALL_API_ARGS = ['url', 'method', 'json_object', 'headers'] ID_KEYS = ['CID', 'UUID', 'MACID'] def clean_name(name: str) -> str: """Clean the device name by removing unwanted characters.""" return name.strip().lower().replace(" ", "_").replace("-", "_").replace(".", "_") def build_base_response( code: int = 0, msg: str | None = None, merge_dict: dict | None = None ) -> dict: """Build the standard response response tuple.""" resp_dict = { "code": code, "msg": msg, "stacktrace": None, "module": None, "traceId": TestDefaults.trace_id, } resp_dict |= merge_dict or {} return resp_dict def build_bypass_v1_response( *, code: int = 0, msg: str | None = None, result_dict: dict | None = None, result_override: dict | None = None, ) -> dict: """Build the standard response response tuple. This function has kw-only arguments. Args: code (int): The response code. msg (str | None): The response message. result_dict (dict | None): The result dictionary. result_override (dict | None): dictionary to override the supplied result_dict. Returns: tuple[dict, int]: The response dictionary and status code. """ resp_dict = { "code": code, "msg": msg, "stacktrace": None, "module": None, "traceId": TestDefaults.trace_id, "result": result_dict or {}, } return resp_dict def build_bypass_v2_response( *, code: int = 0, msg: str | None = None, inner_code: int = 0, inner_result: dict[str, Any] | None = None, inner_result_override: dict | None = None ) -> dict: """Build the standard response response tuple for BypassV2 endpoints. This function has kw-only arguments. Args: code(int): The response code. msg(str | None): The response message. inner_code(int): The inner response code. inner_result(dict | None): The inner response result. inner_result_override(dict | None): dictionary to override the supplied inner_result. Returns: tuple[dict, int]: The response dictionary and status code. """ inner_result = inner_result or {} resp_dict = build_base_response(code, msg) if inner_result_override is not None: inner_result = inner_result | inner_result_override resp_dict['result'] = { "traceId": TestDefaults.trace_id, "code": inner_code, "result": inner_result, } return resp_dict def get_device_map_from_setup_entry(module_list: list[T], setup_entry: str) -> T | None: """Get the device map from the setup entry. Args: module_list (list[T]): The list of device modules. setup_entry (str): The setup entry string. Returns: dict: The device map dictionary. """ for module in module_list: if clean_name(module.setup_entry) == clean_name(setup_entry): return module return None class TestDefaults: """General defaults for API responses and requests. Attributes ---------- token : str Default token for API requests active_time : str Default active time for API responses account_id : str Default account ID for API responses active_time : str Default active time for API responses color: Color (dataclass) Red=50, Green=100, Blue=225, Hue=223, Saturation=77.78, value=88.24 Default Color dataclass contains red, green, blue, hue, saturation and value attributes Methods -------- name(dev_type='NA') Default device name created from "dev_type-NAME" cid(dev_type='NA') Default device cid created from "dev_type-CID" uuid(dev_type='NA') Default device uuid created from "dev_type-UUID" macid(dev_type='NA') Default device macid created from "dev_type-MACID" """ token = 'sample_tk' account_id = 'sample_id' trace_id = "TRACE_ID" terminal_id = "TERMINAL_ID" app_id = "APP_ID" email = 'EMAIL' password = 'PASSWORD' authorization_code = "AUTHORIZATION_CODE" biz_token = "BIZ_TOKEN" region = 'US' country_code = 'US' active_time = 1 time_zone = const.DEFAULT_TZ color: Color = Color(RGB(50, 100, 225)) brightness = 100 color_temp = 100 color_temp_k = Converters.color_temp_pct_to_kelvin(100) bool_toggle = True str_toggle = 'on' bin_toggle = 1 level_int = 1 country_code = 'US' @staticmethod def name(setup_entry: str = 'NA'): """Name of device with format f"{setup_entry}-NAME". Parameters ---------- setup_entry : str Device type use to create default name Returns ------- str Default name for device f"{setup_entry}-NAME" """ return f'{setup_entry}-NAME' @staticmethod def cid(setup_entry='NA'): """CID for a device with format f"{setup_entry}-CID". Parameters ---------- setup_entry : str Device type use to create default cid Returns ------- str Default cid for device f"setup_entry-CID" """ return f'{setup_entry}-CID' @staticmethod def uuid(setup_entry='NA'): """UUID for a device with format f"{setup_entry}-UUID". Parameters ---------- setup_entry : str Device type use to create default UUID Returns ------- str Default uuid for device f"{setup_entry}-UUID" """ return f'{setup_entry}-UUID' @staticmethod def macid(setup_entry='NA'): """MACID for a device with format f"{setup_entry}-MACID". Parameters ---------- setup_entry : str Device type use to create default macid Returns ------- str Default macID for device f"{setup_entry}-MACID" """ return f'{setup_entry}-MACID' @staticmethod def config_module(setup_entry='NA'): """Config module for a device with format f"{setup_entry}-CONFIG". Parameters ---------- setup_entry : str Device type use to create default config module Returns ------- str Default config module for device f"{setup_entry}-CONFIG_MODULE" """ return f'{setup_entry}-CONFIG_MODULE' API_DEFAULTS = CaseInsensitiveDict({ 'accountID': TestDefaults.account_id, 'token': TestDefaults.token, 'timeZone': const.DEFAULT_TZ, 'acceptLanguage': 'en', 'appVersion': const.APP_VERSION, 'phoneBrand': const.PHONE_BRAND, 'phoneOS': const.PHONE_OS, 'userType': const.USER_TYPE, "tk": TestDefaults.token, "mobileId": "MOBILE_ID", "traceId": "TRACE_ID", 'verifyEmail': 'EMAIL', 'nickName': 'NICKNAME', 'password': 'PASSWORD', 'username': 'EMAIL', 'email': 'EMAIL', 'deviceName': 'NAME' }) webdjoe-pyvesync-eb8cecb/src/tests/test_all_devices.py000066400000000000000000000067171507433633000234770ustar00rootroot00000000000000""" This tests all requests made by the pyvesync library with pytest. All tests inherit from the TestBase class which contains the fixtures and methods needed to run the tests. The `helpers.call_api` method is patched to return a mock response. The method, endpoint, headers and json arguments are recorded in YAML files in the api directory, catagorized in folders by module and files by the class name. The default is to record requests that do not exist and compare requests that already exist. If the API changes, set the overwrite argument to True in order to overwrite the existing YAML file with the new request. """ import logging from base_test_cases import TestBase import call_json import call_json_outlets import call_json_bulbs import call_json_fans import call_json_purifiers import call_json_humidifiers import call_json_switches from utils import assert_test, parse_args logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) def test_device_tests(): """Test to ensure all devices have are defined for testing. All devices should have an entry in the DETAILS_RESPONSES dict with response for get_details() method. This test ensures that all devices have been configured for testing. The details response should be located in `{device_type}Details` class of the respective call_json_{device_type} module and the DETAILS_RESPONSE module variable. The class variable with the details response does not matter, the dictionary key of DETAILS_RESPONSES should match the device type. Examples --------- class FanDetails: "Core200SResponse": {'speed': 1, 'device_status': 'on'} DETAILS_RESPONSES = { 'Core200S': FanDetails.Core200SResponse } Asserts ------- Number of devices for each type has a response defined in the respective `call_json` module. See Also -------- src/tests/README.md - README located in the tests directory """ assert call_json_fans.FANS_NUM == len(call_json_fans.DETAILS_RESPONSES) assert call_json_bulbs.BULBS_NUM == len(call_json_bulbs.DETAILS_RESPONSES) assert call_json_outlets.OUTLETS_NUM == len(call_json_outlets.DETAILS_RESPONSES) assert call_json_switches.SWITCHES_NUM == len(call_json_switches.DETAILS_RESPONSES) assert call_json_purifiers.PURIFIERS_NUM == len(call_json_purifiers.DETAILS_RESPONSES) assert call_json_humidifiers.HUMIDIFIERS_NUM == len(call_json_humidifiers.DETAILS_RESPONSES) class TestGeneralAPI(TestBase): """General API testing class for login() and get_devices().""" def test_get_devices(self): """Test get_devices() method request and API response.""" print("Test Device List") self.mock_api.return_value = call_json.DeviceList.device_list_response(), 200 self.run_in_loop(self.manager.get_devices) all_kwargs = parse_args(self.mock_api) assert assert_test(self.manager.get_devices, all_kwargs, None, self.write_api, self.overwrite) assert len(self.manager.devices.bulbs) == call_json_bulbs.BULBS_NUM assert len(self.manager.devices.outlets) == call_json_outlets.OUTLETS_NUM assert len(self.manager.devices.fans) == call_json_fans.FANS_NUM assert len(self.manager.devices.switches) == call_json_switches.SWITCHES_NUM assert len(self.manager.devices.humidifiers) == call_json_humidifiers.HUMIDIFIERS_NUM assert len(self.manager.devices.air_purifiers) == call_json_purifiers.PURIFIERS_NUM webdjoe-pyvesync-eb8cecb/src/tests/test_bulbs.py000066400000000000000000000222401507433633000223210ustar00rootroot00000000000000""" This tests requests made by bulb devices. All tests inherit from the TestBase class which contains the fixtures and methods needed to run the tests. The tests are automatically parametrized by `pytest_generate_tests` in conftest.py. The two methods that are parametrized are `test_details` and `test_methods`. The class variables are used to build the list of devices, test methods and arguments. The `helpers.call_api` method is patched to return a mock response. The method, endpoint, headers and json arguments are recorded in YAML files in the api directory, catagorized in folders by module and files by the class name. The default is to record requests that do not exist and compare requests that already exist. If the API changes, set the overwrite argument to True in order to overwrite the existing YAML file with the new request. See Also -------- `utils.TestBase` - Base class for all tests, containing mock objects `confest.pytest_generate_tests` - Parametrizes tests based on method names & class attributes `call_json_bulbs` - Contains API responses """ import logging import math from dataclasses import asdict import pyvesync.const as const from pyvesync.utils.helpers import Converters from pyvesync.base_devices.bulb_base import VeSyncBulb from base_test_cases import TestBase from utils import assert_test, parse_args from defaults import TestDefaults import call_json_bulbs logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) DEFAULT_COLOR = TestDefaults.color DEFAULT_COLOR_RGB = dict(asdict(DEFAULT_COLOR.rgb)) DEFAULT_COLOR_HSV = dict(asdict(DEFAULT_COLOR.hsv)) RGB_SET = { 'red': 50, 'green': 200, 'blue': 255, } HSV_SET = { 'hue': 200, 'saturation': 50, 'value': 100, } class TestBulbs(TestBase): """Bulbs testing class. This class tests bulb device details and methods. The methods are parametrized from the class variables using `pytest_generate_tests`. The call_json_bulbs module contains the responses for the API requests. The device is instantiated from the details provided by `call_json.DeviceList.device_list_item()`. Inherits from `utils.TestBase`. Instance Attributes ------------------- self.manager : VeSync Instantiated VeSync object self.mock_api : Mock Mock with patched `helpers.call_api` method self.caplog : LogCaptureFixture Pytest fixture for capturing logs Class Attributes ----------------- device : str Name of product class - bulbs bulbs : list List of setup_entry's for bulbs, this variable is named after the device class attribute value base_methods : List[List[str, Dict[str, Any]]] List of common methods for all devices device_methods : Dict[List[List[str, Dict[str, Any]]]] Dictionary of methods specific to setup_entry's for each device. Methods -------- test_details() Test the device details API request and response test_methods() Test device methods API request and response Examples -------- >>> device = 'bulbs' >>> bulbs = call_json_bulbs.bulbs >>> base_methods = [['turn_on'], ['turn_off'], ['update']] >>> device_methods = { 'ESWD16': [['method1'], ['method2', {'kwargs': 'value'}]] } """ device = 'bulbs' bulbs = call_json_bulbs.BULBS base_methods = [['turn_on'], ['turn_off'], ['set_brightness', {'brightness': 50}]] device_methods = { 'ESL100CW': [['set_color_temp', {'color_temp': 50}]], 'ESL100MC': [['set_rgb', RGB_SET], ['set_white_mode']], 'XYD0001': [['set_hsv', HSV_SET], ['set_color_temp', {'color_temp': 50}], ['set_white_mode'] ] } def test_details(self, setup_entry, method): """Test the device details API request and response. This method is automatically parametrized by `pytest_generate_tests` based on class attribute `device` (name of product class - bulbs), device name (bulbs) list of `setup_entry` strings for each object. Example: >>> device = 'bulbs' >>> bulbs = call_json_bulbs.BULBS See Also -------- `utils.TestBase` class docstring `call_json_bulbs` module docstring Notes ------ The device is instantiated using the `call_json.DeviceList.device_list_item()` method. The device details contain the default values set in `utils.Defaults` """ # Set return value for call_api based on call_json_bulb.DETAILS_RESPONSES return_dict = call_json_bulbs.DETAILS_RESPONSES[setup_entry] return_val = (return_dict, 200) self.mock_api.return_value = return_val # Instantiate device from device list return item bulb_obj = self.get_device("bulbs", setup_entry) assert isinstance(bulb_obj, VeSyncBulb) method_call = getattr(bulb_obj, method) self.run_in_loop(method_call) # Parse mock_api args tuple from arg, kwargs to kwargs all_kwargs = parse_args(self.mock_api) # Assert request matches recored request or write new records assert assert_test( method_call, all_kwargs, setup_entry, self.write_api, self.overwrite ) # Assert device details match expected values assert bulb_obj.state.brightness == TestDefaults.brightness if bulb_obj.supports_multicolor: assert self._assert_color(bulb_obj) if bulb_obj.supports_color_temp: assert bulb_obj.state.color_temp == TestDefaults.color_temp assert bulb_obj.state.color_temp_kelvin == Converters.color_temp_pct_to_kelvin(TestDefaults.color_temp) def test_methods(self, setup_entry, method): """Test device methods API request and response. This method is automatically parametrized by `pytest_generate_tests` based on class variables `device` (name of product class - bulbs), device name (bulbs) list of `setup_entry`'s, `base_methods` - list of methods for all devices, and `device_methods` - list of methods for each setup_entry. Example: >>> base_methods = [['turn_on'], ['turn_off'], ['update']] >>> device_methods = { 'setup_entry': [['method1'], ['method2', {'kwargs': 'value'}]] } Notes ----- The response can be a callable that accepts the `kwargs` argument to sync the device response with the API response. In some cases the API returns data from the method call, such as `get_yearly_energy`, in other cases the API returns a simple confirmation the command was successful. See Also -------- `TestBase` class method `call_json_bulbs` module """ # Get method name and kwargs from method fixture method_name = method[0] if len(method) == 2 and isinstance(method[1], dict): method_kwargs = method[1] else: method_kwargs = {} # Set return value for call_api based on call_json_bulbs.METHOD_RESPONSES method_response = call_json_bulbs.METHOD_RESPONSES[setup_entry][method_name] if callable(method_response): if method_kwargs: self.mock_api.return_value = method_response(method_kwargs), 200 else: self.mock_api.return_value = method_response(), 200 else: self.mock_api.return_value = method_response, 200 # Get device configuration from call_json.DeviceList.device_list_item() bulb_obj = self.get_device("bulbs", setup_entry) assert isinstance(bulb_obj, VeSyncBulb) # Get method from device object method_call = getattr(bulb_obj, method[0]) # Ensure method runs based on device configuration if method[0] == 'turn_on': bulb_obj.state.device_status = const.DeviceStatus.OFF elif method[0] == 'turn_off': bulb_obj.state.device_status = const.DeviceStatus.ON # Call method with kwargs if defined if method_kwargs: self.run_in_loop(method_call, **method_kwargs) else: self.run_in_loop(method_call) # Parse arguments from mock_api call into a dictionary all_kwargs = parse_args(self.mock_api) # Assert request matches recored request or write new records assert assert_test( method_call, all_kwargs, setup_entry, self.write_api, self.overwrite ) def _assert_color(self, bulb_obj): assert math.isclose(bulb_obj.state.color.rgb.red, DEFAULT_COLOR.rgb.red, rel_tol=1) assert math.isclose(bulb_obj.state.color.rgb.green, DEFAULT_COLOR.rgb.green, rel_tol=1) assert math.isclose(bulb_obj.state.color.rgb.blue, DEFAULT_COLOR.rgb.blue, rel_tol=1) assert math.isclose(bulb_obj.state.color.hsv.hue, DEFAULT_COLOR.hsv.hue, rel_tol=1) assert math.isclose(bulb_obj.state.color.hsv.saturation, DEFAULT_COLOR.hsv.saturation, rel_tol=1) assert math.isclose(bulb_obj.state.color.hsv.value, DEFAULT_COLOR.hsv.value, rel_tol=1) return True webdjoe-pyvesync-eb8cecb/src/tests/test_fans.py000066400000000000000000000171241507433633000221460ustar00rootroot00000000000000""" This tests requests for FANS (not purifiers or humidifiers). All tests inherit from the TestBase class which contains the fixtures and methods needed to run the tests. The tests are automatically parametrized by `pytest_generate_tests` in conftest.py. The two methods that are parametrized are `test_details` and `test_methods`. The class variables are used to build the list of devices, test methods and arguments. The `helpers.call_api` method is patched to return a mock response. The method, endpoint, headers and json arguments are recorded in YAML files in the api directory, catagorized in folders by module and files by the class name. The default is to record requests that do not exist and compare requests that already exist. If the API changes, set the overwrite argument to True in order to overwrite the existing YAML file with the new request. See Also -------- `utils.TestBase` - Base class for all tests, containing mock objects `confest.pytest_generate_tests` - Parametrizes tests based on method names & class attributes `call_json_fans` - Contains API responses """ import logging from dataclasses import asdict import pyvesync.const as const from pyvesync.base_devices.fan_base import VeSyncFanBase from base_test_cases import TestBase from utils import assert_test, parse_args from defaults import TestDefaults import call_json_fans logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) DEFAULT_COLOR = TestDefaults.color DEFAULT_COLOR_RGB = dict(asdict(DEFAULT_COLOR.rgb)) DEFAULT_COLOR_HSV = dict(asdict(DEFAULT_COLOR.hsv)) class TestFans(TestBase): """Fan testing class. This class tests Fan product details and methods. The methods are parametrized from the class variables using `pytest_generate_tests`. The call_json_fans module contains the responses for the API requests. The device is instantiated from the details provided by `call_json.DeviceList.device_list_item()`. Inherits from `utils.TestBase`. Instance Attributes ------------------- self.manager : VeSync Instantiated VeSync object self.mock_api : Mock Mock with patched `helpers.call_api` method self.caplog : LogCaptureFixture Pytest fixture for capturing logs Class Variables --------------- device : str Name of product class - humidifiers humidifers : list List of setup_entry's for humidifiers, this variable is named after the device variable value base_methods : List[List[str, Dict[str, Any]]] List of common methods for all devices device_methods : Dict[List[List[str, Dict[str, Any]]]] Dictionary of methods specific to device types Methods -------- test_details() Test the device details API request and response test_methods() Test device methods API request and response Examples -------- >>> device = 'fans' >>> fans = call_json_fans.FAN_MODELS >>> base_methods = [['turn_on'], ['turn_off'], ['update']] >>> device_methods = { 'ESWD16': [['method1'], ['method2', {'kwargs': 'value'}]] } """ device = 'fans' fans = call_json_fans.FANS base_methods = [['turn_on'], ['turn_off'], ['set_fan_speed', {'speed': 3}],] device_methods = { 'LTF-F422S': [['turn_off_oscillation',], ['turn_off_mute']], } def test_details(self, setup_entry, method): """Test the device details API request and response. This method is automatically parametrized by `pytest_generate_tests` based on class variables `device` (name of product class - fans), device name (fans) list of setup_entry's. Example: >>> device = 'air_purifiers' >>> air_purifiers = call_json_fans.AIR_MODELS See Also -------- `utils.TestBase` class docstring `call_json_fans` module docstring Notes ------ The device is instantiated using the `call_json.DeviceList.device_list_item()` method. The device details contain the default values set in `utils.Defaults` """ # Set return value for call_api based on call_json_fan.DETAILS_RESPONSES return_dict = call_json_fans.DETAILS_RESPONSES[setup_entry] self.mock_api.return_value = (return_dict, 200) # Instantiate device from device list return item fan_obj = self.get_device("fans", setup_entry) assert isinstance(fan_obj, VeSyncFanBase) method_call = getattr(fan_obj, method) self.run_in_loop(method_call) # Parse mock_api args tuple from arg, kwargs to kwargs all_kwargs = parse_args(self.mock_api) # Assert request matches recorded request or write new records assert assert_test( method_call, all_kwargs, setup_entry, self.write_api, self.overwrite ) def test_methods(self, setup_entry, method): """Test device methods API request and response. This method is automatically parametrized by `pytest_generate_tests` based on class variables `device` (name of product class - humidifiers), device name (humidifiers) list of setup_entry's, `base_methods` - list of methods for all devices, and `device_methods` - list of methods for each device type. Example: >>> base_methods = [['turn_on'], ['turn_off'], ['update']] >>> device_methods = { 'setup_entry': [['method1'], ['method2', {'kwargs': 'value'}]] } Notes ----- The response can be a callable that accepts the `kwargs` argument to sync the device response with the API response. In some cases the API returns data from the method call, such as `get_yearly_energy`, in other cases the API returns a simple confirmation the command was successful. See Also -------- `TestBase` class method `call_json_fans` module """ # Get method name and kwargs from method fixture method_name = method[0] if len(method) == 2 and isinstance(method[1], dict): method_kwargs = method[1] else: method_kwargs = {} # Set return value for call_api based on call_json_fans.METHOD_RESPONSES method_response = call_json_fans.METHOD_RESPONSES[setup_entry][method_name] if callable(method_response): if method_kwargs: self.mock_api.return_value = method_response(method_kwargs), 200 else: self.mock_api.return_value = method_response(), 200 else: self.mock_api.return_value = method_response, 200 # Get device configuration from call_json.DeviceList.device_list_item() fan_obj = self.get_device("fans", setup_entry) assert isinstance(fan_obj, VeSyncFanBase) # Get method from device object method_call = getattr(fan_obj, method[0]) # Ensure method runs based on device configuration if method[0] == 'turn_on': fan_obj.state.device_status = const.DeviceStatus.OFF elif method[0] == 'turn_off': fan_obj.state.device_status = const.DeviceStatus.ON # Call method with kwargs if defined if method_kwargs: self.run_in_loop(method_call, **method_kwargs) else: self.run_in_loop(method_call) # Parse arguments from mock_api call into a dictionary all_kwargs = parse_args(self.mock_api) # Assert request matches recorded request or write new records assert assert_test( method_call, all_kwargs, setup_entry, self.write_api, self.overwrite ) webdjoe-pyvesync-eb8cecb/src/tests/test_humidifiers.py000066400000000000000000000202371507433633000235260ustar00rootroot00000000000000""" This tests requests for Humidifiers. All tests inherit from the TestBase class which contains the fixtures and methods needed to run the tests. The tests are automatically parametrized by `pytest_generate_tests` in conftest.py. The two methods that are parametrized are `test_details` and `test_methods`. The class variables are used to build the list of devices, test methods and arguments. The `helpers.call_api` method is patched to return a mock response. The method, endpoint, headers and json arguments are recorded in YAML files in the api directory, catagorized in folders by module and files by the class name. The default is to record requests that do not exist and compare requests that already exist. If the API changes, set the overwrite argument to True in order to overwrite the existing YAML file with the new request. See Also -------- `utils.TestBase` - Base class for all tests, containing mock objects `confest.pytest_generate_tests` - Parametrizes tests based on method names & class attributes `call_json_fans` - Contains API responses """ import logging from dataclasses import asdict import pyvesync.const as const from pyvesync.base_devices.humidifier_base import VeSyncHumidifier from base_test_cases import TestBase from utils import assert_test, parse_args from defaults import TestDefaults import call_json_humidifiers logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) DEFAULT_COLOR = TestDefaults.color DEFAULT_COLOR_RGB = dict(asdict(DEFAULT_COLOR.rgb)) DEFAULT_COLOR_HSV = dict(asdict(DEFAULT_COLOR.hsv)) RGB_SET = { "red": 50, "green": 200, "blue": 255, } HSV_SET = { "hue": 200, "saturation": 50, "value": 100, } class TestHumidifiers(TestBase): """Humidifier testing class. This class tests Humidifier product details and methods. The methods are parametrized from the class variables using `pytest_generate_tests`. The call_json_fans module contains the responses for the API requests. The device is instantiated from the details provided by `call_json.DeviceList.device_list_item()`. Inherits from `utils.TestBase`. Instance Attributes ------------------- self.manager : VeSync Instantiated VeSync object self.mock_api : Mock Mock with patched `helpers.call_api` method self.caplog : LogCaptureFixture Pytest fixture for capturing logs Class Variables --------------- device : str Name of product class - humidifiers humidifers : list List of setup_entry's for humidifiers, this variable is named after the device variable value base_methods : List[List[str, Dict[str, Any]]] List of common methods for all devices device_methods : Dict[List[List[str, Dict[str, Any]]]] Dictionary of methods specific to device types Methods -------- test_details() Test the device details API request and response test_methods() Test device methods API request and response Examples -------- >>> device = 'humidifiers' >>> humidifiers = call_json_fans.HUMID_MODELS >>> base_methods = [['turn_on'], ['turn_off'], ['update']] >>> device_methods = { 'ESWD16': [['method1'], ['method2', {'kwargs': 'value'}]] } """ device = "humidifiers" humidifiers = call_json_humidifiers.HUMIDIFIERS base_methods = [ ["turn_on"], ["turn_off"], ["turn_on_display"], ["turn_off_display"], ["turn_on_automatic_stop"], ["turn_off_automatic_stop"], ["set_humidity", {"humidity": 50}], ["set_auto_mode"], ["set_manual_mode"], ["set_mist_level", {"level": 2}], ] device_methods = { "LUH-A602S-WUSR": [["set_warm_level", {"warm_level": 3}]], "LEH-S601S-WUS": [["set_drying_mode_enabled", {"mode": False}]], } def test_details(self, setup_entry, method): """Test the device details API request and response. This method is automatically parametrized by `pytest_generate_tests` based on class variables `device` (name of product class - fans), device name (fans) list of setup_entry's. Example: >>> device = 'air_purifiers' >>> air_purifiers = call_json_fans.AIR_MODELS See Also -------- `utils.TestBase` class docstring `call_json_fans` module docstring Notes ------ The device is instantiated using the `call_json.DeviceList.device_list_item()` method. The device details contain the default values set in `utils.Defaults` """ # Set return value for call_api based on call_json_fan.DETAILS_RESPONSES return_dict = call_json_humidifiers.DETAILS_RESPONSES[setup_entry] return_val = (return_dict, 200) self.mock_api.return_value = return_val # Instantiate device from device list return item fan_obj = self.get_device("humidifiers", setup_entry) assert isinstance(fan_obj, VeSyncHumidifier) method_call = getattr(fan_obj, method) self.run_in_loop(method_call) # Parse mock_api args tuple from arg, kwargs to kwargs all_kwargs = parse_args(self.mock_api) # Assert request matches recorded request or write new records assert assert_test( method_call, all_kwargs, setup_entry, self.write_api, self.overwrite ) def test_methods(self, setup_entry, method): """Test device methods API request and response. This method is automatically parametrized by `pytest_generate_tests` based on class variables `device` (name of product class - humidifiers), device name (humidifiers) list of setup_entry's, `base_methods` - list of methods for all devices, and `device_methods` - list of methods for each device type. Example: >>> base_methods = [['turn_on'], ['turn_off'], ['update']] >>> device_methods = { 'setup_entry': [['method1'], ['method2', {'kwargs': 'value'}]] } Notes ----- The response can be a callable that accepts the `kwargs` argument to sync the device response with the API response. In some cases the API returns data from the method call, such as `get_yearly_energy`, in other cases the API returns a simple confirmation the command was successful. See Also -------- `TestBase` class method `call_json_fans` module """ # Get method name and kwargs from method fixture method_name = method[0] if len(method) == 2 and isinstance(method[1], dict): method_kwargs = method[1] else: method_kwargs = {} # Set return value for call_api based on call_json_fans.METHOD_RESPONSES method_response = call_json_humidifiers.METHOD_RESPONSES[setup_entry][method_name] if callable(method_response): if method_kwargs: self.mock_api.return_value = method_response(method_kwargs), 200 else: self.mock_api.return_value = method_response(), 200 else: self.mock_api.return_value = method_response, 200 # Get device configuration from call_json.DeviceList.device_list_item() fan_obj = self.get_device("humidifiers", setup_entry) assert isinstance(fan_obj, VeSyncHumidifier) # Get method from device object method_call = getattr(fan_obj, method[0]) # Ensure method runs based on device configuration if method[0] == "turn_on": fan_obj.state.device_status = const.DeviceStatus.OFF elif method[0] == "turn_off": fan_obj.state.device_status = const.DeviceStatus.ON # Call method with kwargs if defined if method_kwargs: self.run_in_loop(method_call, **method_kwargs) else: self.run_in_loop(method_call) # Parse arguments from mock_api call into a dictionary all_kwargs = parse_args(self.mock_api) # Assert request matches recored request or write new records assert assert_test( method_call, all_kwargs, setup_entry, self.write_api, self.overwrite ) webdjoe-pyvesync-eb8cecb/src/tests/test_outlets.py000066400000000000000000000246151507433633000227210ustar00rootroot00000000000000""" This tests all requests made by outlet devices. All tests inherit from the TestBase class which contains the fixtures and methods needed to run the tests. The tests are automatically parametrized by `pytest_generate_tests` in conftest.py. The two methods that are parametrized are `test_details` and `test_methods`. The class variables are used to build the list of devices, test methods and arguments. The `helpers.call_api` method is patched to return a mock response. The method, endpoint, headers and json arguments are recorded in YAML files in the api directory, catagorized in folders by module and files by the class name. The default is to record requests that do not exist and compare requests that already exist. If the API changes, set the overwrite argument to True in order to overwrite the existing YAML file with the new request. See Also -------- `utils.TestBase` - Base class for all tests, containing mock objects `confest.pytest_generate_tests` - Parametrizes tests based on method names & class attributes `call_json_outlets` - Contains API responses """ import pytest import logging import pyvesync.const as const from pyvesync.base_devices.outlet_base import VeSyncOutlet # from pyvesync.models.outlet_models import ResponseEnergyHistory from base_test_cases import TestBase from utils import assert_test, parse_args import call_json import call_json_outlets logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) OUTLET_DEV_TYPES = call_json_outlets.OUTLETS POWER_METHODS = ['get_energy_update'] OUTLET_PARAMS = [[dev, method] for dev in OUTLET_DEV_TYPES for method in POWER_METHODS] class TestOutlets(TestBase): """Outlets testing class. This class tests outlets device details and methods. The methods are parametrized from the class variables using `pytest_generate_tests`. The call_json_outlets module contains the responses for the API requests. The device is instantiated from the details provided by `call_json.DeviceList.device_list_item()`. Inherits from `utils.TestBase`. Instance Attributes ------------------- self.manager : VeSync Instantiated VeSync object self.mock_api : Mock Mock with patched `helpers.call_api` method self.caplog : LogCaptureFixture Pytest fixture for capturing logs Class Attributes --------------- device : str Name of device type - outlets outlets : list List of device types for outlets, this variable is named after the device variable value base_methods : List[List[str, Dict[str, Any]]] List of common methods for all devices device_methods : Dict[List[List[str, Dict[str, Any]]]] Dictionary of methods specific to device types Methods -------- test_details() Test the device details API request and response test_methods() Test device methods API request and response Examples -------- >>> device = 'outlets' >>> outlets = ['ESW01-USA', 'ESW01-EU', 'ESW01-AU'] >>> base_methods = [['turn_on'], ['turn_off'], ['update']] >>> device_methods = { 'dev_type': [['method1'], ['method2', {'kwargs': 'value'}]] } """ device = 'outlets' outlets = call_json_outlets.OUTLETS base_methods = [ ['turn_on'], ['turn_off'] ] device_methods = { 'ESW15-USA': [ ['turn_on_nightlight'], ['turn_off_nightlight'], ['get_weekly_energy'], ['get_monthly_energy'], ['get_yearly_energy'] ], 'wifi-switch-1.3': [ ['get_weekly_energy'], ['get_monthly_energy'], ['get_yearly_energy'] ], # 'ESW03-USA': [ # ['get_weekly_energy'], # ['get_monthly_energy'], # ['get_yearly_energy'] # ], 'ESW01-EU': [ ['get_weekly_energy'], ['get_monthly_energy'], ['get_yearly_energy'] ], 'ESO15-TB': [ ['get_weekly_energy'], ['get_monthly_energy'], ['get_yearly_energy'] ] } def test_details(self, setup_entry, method): """Test the device details API request and response. This method is automatically parametrized by `pytest_generate_tests` based on class variables `device` (name of device type - outlets), device name (outlets) list of device types. Example: >>> device = 'outlets' >>> outlets = ['ESW01-USA', 'ESW01-EU', 'ESW01-AU'] See Also -------- `utils.TestBase` class docstring `call_json_outlets` module docstring Notes ------ The device is instantiated using the `call_json.DeviceList.device_list_item()` method. The device details contain the default values set in `utils.Defaults` """ # Get response for device details details_response = call_json_outlets.DETAILS_RESPONSES[setup_entry] if callable(details_response): response_dict = details_response() else: response_dict = details_response self.mock_api.return_value = response_dict, 200 # Get device configuration outlet_obj = self.get_device("outlets", setup_entry) assert isinstance(outlet_obj, VeSyncOutlet) # Call get_details() directly method_call = getattr(outlet_obj, method) self.run_in_loop(method_call) # Parse arguments from mock_api call into dictionary all_kwargs = parse_args(self.mock_api) # Set both write_api and overwrite to True to update YAML files assert assert_test( method_call, all_kwargs, setup_entry, write_api=self.write_api, overwrite=self.overwrite, ) # Test bad responses self.mock_api.reset_mock() if setup_entry == 'wifi-switch-1.3': self.mock_api.return_value = (None, 400) else: self.mock_api.return_value = call_json.DETAILS_BADCODE, 200 self.run_in_loop(outlet_obj.get_details) assert 'details' in self.caplog.text def test_methods(self, setup_entry: str, method): """Test device methods API request and response. This method is automatically parametrized by `pytest_generate_tests` based on class variables `device` (name of device type - outlets), device name (outlets) list of device types, `base_methods` - list of methods for all devices, and `device_methods` - list of methods for each device type. Example: >>> base_methods = [['turn_on'], ['turn_off'], ['update']] >>> device_methods = { 'dev_type': [['method1'], ['method2', {'kwargs': 'value'}]] } Notes ----- The response can be a callable that accepts the `kwargs` argument to sync the device response with the API response. In some cases the API returns data from the method call, such as `get_yearly_energy`, in other cases the API returns a simple confirmation the command was successful. See Also -------- `TestBase` class method `call_json_outlets` module """ # Get method name and kwargs from method fixture method_name = method[0] if len(method) == 2 and isinstance(method[1], dict): method_kwargs = method[1] else: method_kwargs = {} # Set return value for call_api based on METHOD_RESPONSES method_response = call_json_outlets.METHOD_RESPONSES[setup_entry][method_name] if callable(method_response): if method_kwargs: resp_dict = method_response(**method_kwargs) else: resp_dict = method_response() else: resp_dict = method_response self.mock_api.return_value = resp_dict, 200 # Get device configuration outlet_obj = self.get_device("outlets", setup_entry) assert isinstance(outlet_obj, VeSyncOutlet) # Get method from device object method_call = getattr(outlet_obj, method[0]) # Ensure method runs based on device configuration if method[0] == 'turn_on': outlet_obj.state.device_status = const.DeviceStatus.OFF elif method[0] == 'turn_off': outlet_obj.state.device_status = const.DeviceStatus.ON # Call method with kwargs if present if method_kwargs: self.run_in_loop(method_call, **method_kwargs) else: self.run_in_loop(method_call) # Parse arguments from mock_api call into dictionary all_kwargs = parse_args(self.mock_api) # Assert request matches recorded request or write new records assert assert_test( method_call, all_kwargs, setup_entry, self.write_api, self.overwrite ) # Test bad responses self.mock_api.reset_mock() if setup_entry == 'wifi-switch-1.3': self.mock_api.return_value = (None, 400) else: self.mock_api.return_value = call_json.DETAILS_BADCODE, 200 if method[0] == 'turn_on': outlet_obj.state.device_status = const.DeviceStatus.OFF if method[0] == 'turn_off': outlet_obj.state.device_status = const.DeviceStatus.ON if 'energy' in method[0]: return bad_return = self.run_in_loop(method_call) assert bad_return is False @pytest.mark.parametrize('setup_entry', [d for d in OUTLET_DEV_TYPES]) def test_power(self, setup_entry): """Test outlets power history methods.""" outlet_obj = self.get_device("outlets", setup_entry) assert isinstance(outlet_obj, VeSyncOutlet) if not outlet_obj.supports_energy: pytest.skip(f"{setup_entry} does not support energy monitoring.") resp_dict = call_json_outlets.ENERGY_HISTORY self.mock_api.return_value = resp_dict, 200 self.run_in_loop(outlet_obj.update_energy) assert self.mock_api.call_count == 3 assert outlet_obj.state.weekly_history is not None assert outlet_obj.state.monthly_history is not None assert outlet_obj.state.yearly_history is not None self.mock_api.reset_mock() if setup_entry == 'wifi-switch-1.3': self.mock_api.return_value = (None, 400) else: self.mock_api.return_value = call_json.DETAILS_BADCODE, 200 webdjoe-pyvesync-eb8cecb/src/tests/test_purifiers.py000066400000000000000000000203011507433633000232160ustar00rootroot00000000000000""" This tests requests for Air Purifiers. All tests inherit from the TestBase class which contains the fixtures and methods needed to run the tests. The tests are automatically parametrized by `pytest_generate_tests` in conftest.py. The two methods that are parametrized are `test_details` and `test_methods`. The class variables are used to build the list of devices, test methods and arguments. The `helpers.call_api` method is patched to return a mock response. The method, endpoint, headers and json arguments are recorded in YAML files in the api directory, catagorized in folders by module and files by the class name. The default is to record requests that do not exist and compare requests that already exist. If the API changes, set the overwrite argument to True in order to overwrite the existing YAML file with the new request. See Also -------- `utils.TestBase` - Base class for all tests, containing mock objects `confest.pytest_generate_tests` - Parametrizes tests based on method names & class attributes `call_json_fans` - Contains API responses """ import logging import pytest import pyvesync.const as const from pyvesync.base_devices.purifier_base import VeSyncPurifier from base_test_cases import TestBase from utils import assert_test, parse_args import call_json_purifiers logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) class TestAirPurifiers(TestBase): """Air Purifier testing class. This class tests Air Purifier device details and methods. The methods are parametrized from the class variables using `pytest_generate_tests`. The call_json_fans module contains the responses for the API requests. The device is instantiated from the details provided by `call_json.DeviceList.device_list_item()`. Inherits from `utils.TestBase`. Instance Attributes ------------------- self.manager : VeSync Instantiated VeSync object self.mock_api : Mock Mock with patched `helpers.call_api` method self.caplog : LogCaptureFixture Pytest fixture for capturing logs Class Attributes --------------- device : str Name of product class - air_purifiers air_purifiers : list List of setup_entry's for air purifiers, this variable is named after the device variable value base_methods : List[List[str, Dict[str, Any]]] List of common methods for all devices device_methods : Dict[List[List[str, Dict[str, Any]]]] Dictionary of methods specific to device types Methods -------- test_details() Test the device details API request and response test_methods() Test device methods API request and response Examples -------- >>> device = 'air_purifiers' >>> air_purifiers = call_json_fans.AIR_MODELS >>> base_methods = [['turn_on'], ['turn_off'], ['update']] >>> device_methods = { 'ESWD16': [['method1'], ['method2', {'kwargs': 'value'}]] } """ device = "air_purifiers" air_purifiers = call_json_purifiers.PURIFIER_MODELS base_methods = [ ["turn_on"], ["turn_off"], ["set_sleep_mode"], ["set_manual_mode"], ["set_fan_speed", {"speed": 3}], ] # TODO: Add timer tests device_methods = { "Core300S": [["set_auto_mode"], ["turn_on_display"], ["turn_off_display"]], "Core400S": [["set_auto_mode"], ["turn_on_display"], ["turn_off_display"]], "Core600S": [["set_auto_mode"], ["turn_on_display"], ["turn_off_display"]], } def test_details(self, setup_entry, method): """Test the device details API request and response. This method is automatically parametrized by `pytest_generate_tests` based on class variables `device` (name of product class - air_purifiers), device name (air_purifiers) list of device types. Example: >>> device = 'air_purifiers' >>> air_purifiers = call_json_fans.AIR_MODELS See Also -------- `utils.TestBase` class docstring `call_json_fans` module docstring Notes ------ The device is instantiated using the `call_json.DeviceList.device_list_item()` method. The device details contain the default values set in `utils.Defaults` """ # Set return value for call_api based on call_json_fan.DETAILS_RESPONSES return_dict = call_json_purifiers.DETAILS_RESPONSES[setup_entry] return_val = (return_dict, 200) self.mock_api.return_value = return_val # Instantiate device from device list return item fan_obj = self.get_device("air_purifiers", setup_entry) assert isinstance(fan_obj, VeSyncPurifier) method_call = getattr(fan_obj, method) self.run_in_loop(method_call) # Parse mock_api args tuple from arg, kwargs to kwargs all_kwargs = parse_args(self.mock_api) # Assert request matches recored request or write new records assert assert_test( method_call, all_kwargs, setup_entry, self.write_api, self.overwrite ) def test_methods(self, setup_entry, method): """Test device methods API request and response. This method is automatically parametrized by `pytest_generate_tests` based on class variables `device` (name of product class - air_purifiers), device name (air_purifiers) list of setup_entry's, `base_methods` - list of methods for all devices, and `device_methods` - list of methods for each device type. Example: >>> base_methods = [['turn_on'], ['turn_off'], ['update']] >>> device_methods = { 'setup_entry': [['method1'], ['method2', {'kwargs': 'value'}]] } Notes ----- The response can be a callable that accepts the `kwargs` argument to sync the device response with the API response. In some cases the API returns data from the method call, such as `get_yearly_energy`, in other cases the API returns a simple confirmation the command was successful. See Also -------- `TestBase` class method `call_json_fans` module """ # TODO: FIX `clear_timer` recorded API request in yaml if method[0] == "clear_timer": pytest.skip("Incorrect clear_timer API request") # Get method name and kwargs from method fixture method_name = method[0] if len(method) == 2 and isinstance(method[1], dict): method_kwargs = method[1] else: method_kwargs = {} # Set return value for call_api based on call_json_fans.METHOD_RESPONSES method_response = call_json_purifiers.METHOD_RESPONSES[setup_entry][method_name] if callable(method_response): if method_kwargs: return_dict = method_response(method_kwargs) else: return_dict = method_response() else: return_dict = method_response self.mock_api.return_value = (return_dict, 200) # Get device configuration from call_json.DeviceList.device_list_item() fan_obj = self.get_device("air_purifiers", setup_entry) assert isinstance(fan_obj, VeSyncPurifier) # Get method from device object method_call = getattr(fan_obj, method[0]) # Ensure method runs based on device configuration if method[0] == "turn_on": fan_obj.state.device_status = const.DeviceStatus.OFF elif method[0] == "turn_off": fan_obj.state.device_status = const.DeviceStatus.ON elif method[0] == "change_fan_speed": fan_obj.state.mode = const.PurifierModes.MANUAL # elif method[0] == 'clear_timer': # fan_obj.timer = call_json_fans.FAN_TIMER # Call method with kwargs if defined if method_kwargs: self.run_in_loop(method_call, **method_kwargs) else: self.run_in_loop(method_call) # Parse arguments from mock_api call into a dictionary all_kwargs = parse_args(self.mock_api) # Assert request matches recored request or write new records assert assert_test( method_call, all_kwargs, setup_entry, self.write_api, self.overwrite ) webdjoe-pyvesync-eb8cecb/src/tests/test_switches.py000066400000000000000000000215541507433633000230520ustar00rootroot00000000000000""" This tests requests made by switch devices. All tests inherit from the TestBase class which contains the fixtures and methods needed to run the tests. The tests are automatically parametrized by `pytest_generate_tests` in conftest.py. The two methods that are parametrized are `test_details` and `test_methods`. The class variables are used to build the list of devices, test methods and arguments. The `helpers.call_api` method is patched to return a mock response. The method, endpoint, headers and json arguments are recorded in YAML files in the api directory, catagorized in folders by module and files by the class name. The default is to record requests that do not exist and compare requests that already exist. If the API changes, set the overwrite argument to True in order to overwrite the existing YAML file with the new request. See Also -------- `utils.TestBase` - Base class for all tests, containing mock objects `confest.pytest_generate_tests` - Parametrizes tests based on method names & class attributes `call_json_switches` - Contains API responses """ import logging from pyvesync.base_devices.switch_base import VeSyncSwitch import pyvesync.const as const from base_test_cases import TestBase from utils import assert_test, parse_args from defaults import TestDefaults import call_json import call_json_switches logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) DEFAULT_COLOR = TestDefaults.color.rgb COLOR_DICT = { 'red': DEFAULT_COLOR.red, 'blue': DEFAULT_COLOR.blue, 'green': DEFAULT_COLOR.green, } NEW_BRIGHTNESS = 75 NEW_COLOR_TEMP = 40 class TestSwitches(TestBase): """Switches testing class. This class tests switch device details and methods. The methods are parametrized from the class variables using `pytest_generate_tests`. The call_json_switches module contains the responses for the API requests. The device is instantiated from the details provided by `call_json.DeviceList.device_list_item()`. Inherits from `utils.TestBase`. Instance Attributes ------------------- self.manager : VeSync Instantiated VeSync object self.mock_api : Mock Mock with patched `helpers.call_api` method self.caplog : LogCaptureFixture Pytest fixture for capturing logs Class Variables --------------- device : str Name of device type - switches switches : list List of device types for switches, this variable is named after the device variable value base_methods : List[List[str, Dict[str, Any]]] List of common methods for all devices device_methods : Dict[List[List[str, Dict[str, Any]]]] Dictionary of methods specific to device types Methods -------- test_details() Test the device details API request and response test_methods() Test device methods API request and response Examples -------- >>> device = 'switches' >>> switches = call_json_switches.SWITCHES >>> base_methods = [['turn_on'], ['turn_off'], ['update']] >>> device_methods = { 'ESWD16': [['method1'], ['method2', {'kwargs': 'value'}]] } """ device = 'switches' switches = call_json_switches.SWITCHES base_methods = [['turn_on'], ['turn_off']] device_methods = { 'ESWD16': [['turn_on_indicator_light'], ['turn_on_rgb_backlight'], ['set_backlight_color', COLOR_DICT], ['set_brightness', {'brightness': NEW_BRIGHTNESS}]], } def test_details(self, setup_entry, method): """Test the device details API request and response. This method is automatically parametrized by `pytest_generate_tests` based on class variables `device` (name of device type - switches), device name (switches) list of device types. Example: >>> device = 'switches' >>> switches = call_json_switches.SWITCHES See Also -------- `utils.TestBase` class docstring `call_json_switches` module docstring Notes ------ The device is instantiated using the `call_json.DeviceList.device_list_item()` method. The device details contain the default values set in `utils.Defaults` """ # Set return value for call_api based on call_json_bulb.DETAILS_RESPONSES resp_dict = call_json_switches.DETAILS_RESPONSES[setup_entry] self.mock_api.return_value = resp_dict, 200 # Instantiate device from device list return item switch_obj = self.get_device("switches", setup_entry) assert isinstance(switch_obj, VeSyncSwitch) method_call = getattr(switch_obj, method) self.run_in_loop(method_call) # Parse mock_api args tuple from arg, kwargs to kwargs all_kwargs = parse_args(self.mock_api) # Assert request matches recored request or write new records assert assert_test( method_call, all_kwargs, setup_entry, self.write_api, self.overwrite, ) # Assert device details match expected values if switch_obj.supports_dimmable: assert switch_obj.state.brightness == TestDefaults.brightness assert switch_obj.state.indicator_status == 'on' assert switch_obj.state.backlight_status == 'on' assert switch_obj.state.backlight_color is not None assert switch_obj.state.backlight_color.rgb.to_dict() == COLOR_DICT self.mock_api.reset_mock() bad_dict = call_json.DETAILS_BADCODE self.mock_api.return_value = bad_dict, 200 self.run_in_loop(switch_obj.get_details) assert 'Unknown error' in self.caplog.records[-1].message def test_methods(self, setup_entry, method): """Test switch methods API request and response. This method is automatically parametrized by `pytest_generate_tests` based on class variables `device` (name of device type - switches), device name (switches) list of device types, `base_methods` - list of methods for all devices, and `device_methods` - list of methods for each device type. Example: >>> base_methods = [['turn_on'], ['turn_off'], ['update']] >>> device_methods = { 'dev_type': [['method1'], ['method2', {'kwargs': 'value'}]] } Notes ----- The response can be a callable that accepts the `kwargs` argument to sync the device response with the API response. In some cases the API returns data from the method call, such as `get_yearly_energy`, in other cases the API returns a simple confirmation the command was successful. See Also -------- `TestBase` class method `call_json_switches` module """ # Get method name and kwargs from method fixture method_name = method[0] if len(method) == 2 and isinstance(method[1], dict): method_kwargs = method[1] else: method_kwargs = {} # Set return value for call_api based on call_json_switches.METHOD_RESPONSES method_response = call_json_switches.METHOD_RESPONSES[setup_entry][method_name] if callable(method_response): if method_kwargs: resp_dict = method_response(**method_kwargs) else: resp_dict = method_response() else: resp_dict = method_response self.mock_api.return_value = resp_dict, 200 # Get device configuration from call_json.DeviceList.device_list_item() switch_obj = self.get_device("switches", setup_entry) assert isinstance(switch_obj, VeSyncSwitch) # Get method from device object method_call = getattr(switch_obj, method[0]) # Ensure method runs based on device configuration if method[0] == 'turn_on': switch_obj.state.device_status = const.DeviceStatus.OFF elif method[0] == 'turn_off': switch_obj.state.device_status = const.DeviceStatus.ON # Call method with kwargs if defined if method_kwargs: self.run_in_loop(method_call, **method_kwargs) else: self.run_in_loop(method_call) # Parse arguments from mock_api call into a dictionary all_kwargs = parse_args(self.mock_api) # Assert request matches recored request or write new records assert assert_test( method_call, all_kwargs, setup_entry, self.write_api, self.overwrite ) self.mock_api.reset_mock() resp_dict = call_json.DETAILS_BADCODE self.mock_api.return_value = resp_dict, 200 if method_kwargs: return_val = self.run_in_loop(method_call, **method_kwargs) assert return_val is False else: return_val = self.run_in_loop(method_call) assert return_val is False webdjoe-pyvesync-eb8cecb/src/tests/test_x_vesync_api_responses.py000066400000000000000000000121151507433633000260020ustar00rootroot00000000000000"""General VeSync tests.""" import logging import asyncio import orjson import pytest from unittest.mock import patch, MagicMock from pyvesync import VeSync from pyvesync.utils.errors import VeSyncRateLimitError, VeSyncServerError from pyvesync.const import API_BASE_URL_US from pyvesync.utils.errors import ( VeSyncAPIStatusCodeError ) import call_json from defaults import TestDefaults from aiohttp_mocker import AiohttpMockSession DEFAULT_ENDPOINT = '/endpoint' DEFAULT_POST_DATA = {'key': 'value'} PARAM_ARGS = "endpoint, method, resp_bytes, resp_status" # Successful API calls should return the response in bytes and a 200 status code SUCCESS_RESP = call_json.response_body(0, 'Success') # Rate limit errors should raise an exception in `async_call_api` RATE_LIMIT_CODE = -11003000 RATE_LIMIT_RESP = call_json.response_body(RATE_LIMIT_CODE, "Rate limit exceeded") # Server errors should raise an exception in `async_call_api` SERVER_ERROR = -11102000 SERVER_ERROR_RESP = call_json.response_body(SERVER_ERROR, "Server error") # Status code errors should raise an exception in `async_call_api` STATUS_CODE_ERROR = 400 STATUS_CODE_RESP = None # Login errors should not raise an exception in `async_call_api`, but are raised in login() method STATUS_CODE_ERROR = 400 STATUS_CODE_RESP = None # Device errors should return the response and a 200 status code # with no exception thrown by `async_call_api` DEVICE_ERROR_CODE = -11901000 DEVICE_ERROR_RESP = call_json.response_body(DEVICE_ERROR_CODE, "Device error") class TestApiFunc: """Test call_api() method.""" @pytest.fixture(autouse=True, scope='function') def setup(self, caplog): """Fixture to instantiate VeSync object, start logging and start Mock. Attributes ---------- self.mock_api : Mock self.manager : VeSync self.caplog : LogCaptureFixture Yields ------ Class instance with mocked call_api() function and VeSync object """ self.caplog = caplog self.caplog.set_level(logging.DEBUG) self.loop = asyncio.new_event_loop() self.mock = MagicMock() self.manager = VeSync('EMAIL', 'PASSWORD') self.manager.verbose = True self.manager.enabled = True self.manager.auth._token = TestDefaults.token self.manager.auth._account_id = TestDefaults.account_id caplog.set_level(logging.DEBUG) yield self.mock.stop() self.loop.stop() async def run_coro(self, coro): """Run a coroutine in the event loop.""" return await coro def run_in_loop(self, func, *args, **kwargs): """Run a function in the event loop.""" return self.loop.run_until_complete(self.run_coro(func(*args, **kwargs))) @patch("pyvesync.vesync.ClientSession") def test_api_success(self, mock): """Test successful api call - returns tuple of response bytes and status code.""" mock.return_value.request.return_value = AiohttpMockSession( method='post', url=API_BASE_URL_US + DEFAULT_ENDPOINT, status=200, response=orjson.dumps(SUCCESS_RESP), ) resp = self.run_in_loop(self.manager.async_call_api, DEFAULT_ENDPOINT, method="get") mock_dict, mock_status = resp assert SUCCESS_RESP == mock_dict assert mock_status == 200 @patch("pyvesync.vesync.ClientSession") def test_api_rate_limit(self, mock): """Test rate limit error - raises `VeSyncRateLimitError` from `VeSync.async_call_api`.""" rate_limit_resp = call_json.response_body(RATE_LIMIT_CODE, "Rate limit exceeded") mock.return_value.request.return_value = AiohttpMockSession( method="post", url=API_BASE_URL_US + DEFAULT_ENDPOINT, status=200, response=orjson.dumps(rate_limit_resp), ) with pytest.raises(VeSyncRateLimitError): self.run_in_loop(self.manager.async_call_api, DEFAULT_ENDPOINT, 'post', json_object=DEFAULT_POST_DATA) @patch("pyvesync.vesync.ClientSession") def test_api_server_error(self, mock): """Test server error - raises `VeSyncServerError` from `VeSync.async_call_api`.""" mock.return_value.request.return_value = AiohttpMockSession( method='post', url=API_BASE_URL_US + DEFAULT_ENDPOINT, status=200, response=orjson.dumps(SERVER_ERROR_RESP), ) with pytest.raises(VeSyncServerError): self.run_in_loop(self.manager.async_call_api, DEFAULT_ENDPOINT, 'post', json_object=DEFAULT_POST_DATA) @patch("pyvesync.vesync.ClientSession") def test_api_status_code_error(self, mock): """Test status code error - raises `VeSyncAPIStatusCodeError` from `VeSync.async_call_api`.""" mock.return_value.request.return_value = AiohttpMockSession( method='get', url=API_BASE_URL_US + DEFAULT_ENDPOINT, status=404, response=None, ) with pytest.raises(VeSyncAPIStatusCodeError): self.run_in_loop(self.manager.async_call_api, DEFAULT_ENDPOINT, 'get') webdjoe-pyvesync-eb8cecb/src/tests/test_x_vesync_login.py000066400000000000000000000155621507433633000242510ustar00rootroot00000000000000"""Test VeSync login method.""" # from aiohttp.web_response import Response from unittest.mock import MagicMock import pytest import orjson from pyvesync.utils.errors import VeSyncLoginError from pyvesync import const from pyvesync.utils.helpers import Helpers from pyvesync.models.vesync_models import ( ResponseLoginModel, RequestLoginTokenModel, RequestGetTokenModel, ) from aiohttp_mocker import AiohttpMockSession from base_test_cases import TestApiFunc, TestBase import call_json from defaults import TestDefaults TOKEN_ERROR_CODE = -11001000 TOKEN_ERROR_RESP = call_json.response_body(TOKEN_ERROR_CODE, "Token expired") US_BASE_URL = const.API_BASE_URL_US EU_BASE_URL = const.API_BASE_URL_EU DEFAULT_ENDPOINT = '/endpoint' INVALID_PASSWORD_ERROR = -11201000 INVALID_PASSWORD_RESP = {'code': INVALID_PASSWORD_ERROR, 'msg': 'success'} LOGIN_RET_BODY = { "traceId": TestDefaults.trace_id, "code": 0, "msg": "", "stacktrace": None, "result": { "accountID": TestDefaults.account_id, "avatarIcon": "", "acceptLanguage": "en", "gdprStatus": True, "nickName": "nick", "userType": "1", "token": TestDefaults.token, "countryCode": TestDefaults.country_code, }, } LOGIN_REQUESTS = call_json.LoginRequests LOGIN_RESPONSES = call_json.LoginResponses LOGIN_ENDPOINT = '/globalPlatform/api/accountAuth/v1/authByPWDOrOTM' LOGIN_TOKEN_ENDPOINT = '/user/api/accountManage/v1/loginByAuthorizeCode4Vesync' VARIABLE_FIELDS = { 'traceId': TestDefaults.trace_id, 'appId': TestDefaults.app_id, 'terminalId': TestDefaults.terminal_id, } request_login_token_model = RequestLoginTokenModel( authorizeCode=TestDefaults.authorization_code, method='loginByAuthorizeCode4Vesync', userCountryCode=TestDefaults.country_code, traceId=TestDefaults.trace_id, terminalId=TestDefaults.terminal_id, ) request_get_token_model = RequestGetTokenModel( email=TestDefaults.email, password=TestDefaults.password, method='authByPWDOrOTM', userCountryCode=TestDefaults.country_code, traceId=TestDefaults.trace_id, terminalId=TestDefaults.terminal_id, appID=TestDefaults.app_id, ) response_get_token_model = ResponseLoginModel.from_dict(LOGIN_RESPONSES.GET_TOKEN_RESPONSE_SUCCESS) response_login_token_model = ResponseLoginModel.from_dict(LOGIN_RESPONSES.LOGIN_RESPONSE_SUCCESS) class TestLoginModels(TestBase): """Test VeSync login response models.""" def test_login_success(self): """Test login response model.""" self.mock_api.side_effect = [ (response_get_token_model.to_dict(), 200), (response_login_token_model.to_dict(), 200) ] self.run_in_loop(self.manager.login) assert self.mock_api.call_count == 2 assert request_get_token_model.email == TestDefaults.email assert request_get_token_model.password == Helpers.hash_password(TestDefaults.password) assert self.manager.token == TestDefaults.token assert self.manager.account_id == TestDefaults.account_id call_list = self.mock_api.call_args_list assert call_list[0].args[0] == LOGIN_ENDPOINT assert call_list[0].args[1] == 'post' assert call_list[1].args[0] == LOGIN_TOKEN_ENDPOINT assert call_list[1].args[1] == 'post' get_token_kwargs = call_list[0].kwargs['json_object'] get_token_kwargs.appID = TestDefaults.app_id get_token_kwargs.terminalId = TestDefaults.terminal_id get_token_kwargs.traceId = TestDefaults.trace_id assert get_token_kwargs == request_get_token_model login_token_kwargs = call_list[1].kwargs['json_object'] login_token_kwargs.terminalId = TestDefaults.terminal_id login_token_kwargs.traceId = TestDefaults.trace_id assert login_token_kwargs == request_login_token_model class TestLogin(TestApiFunc): """Test VeSync login class.""" def test_invalid_password(self): """Test login with invalid user/password.""" self.mock_api.return_value.request.return_value = AiohttpMockSession( method='post', url=US_BASE_URL + self.default_endpoint, status=200, response=orjson.dumps(INVALID_PASSWORD_RESP), ) with pytest.raises(VeSyncLoginError): self.run_in_loop(self.manager.login) def _build_token_request(self): request_get_token_model = RequestGetTokenModel( email=TestDefaults.email, password=TestDefaults.password, method='authByPWDOrOTM', userCountryCode=TestDefaults.country_code, traceId=TestDefaults.trace_id, terminalId=TestDefaults.terminal_id, appID=TestDefaults.app_id, ) return request_get_token_model def test_token_expired(self): """Test login with expired token.""" token_error_response = orjson.dumps(TOKEN_ERROR_RESP) get_token_endpoint = LOGIN_ENDPOINT login_token_endpoint = LOGIN_TOKEN_ENDPOINT final_response = call_json.DeviceList.device_list_response() first_final_endpoint = '/cloud/v1/deviceManaged/devices' get_token_response = orjson.dumps(LOGIN_RESPONSES.GET_TOKEN_RESPONSE_SUCCESS) login_token_response = orjson.dumps(LOGIN_RESPONSES.LOGIN_RESPONSE_SUCCESS) self.mock_api.return_value = MagicMock() self.mock_api.return_value.request.side_effect = [ AiohttpMockSession( method='post', url=US_BASE_URL + first_final_endpoint, status=200, response=token_error_response, ), AiohttpMockSession( method='post', url=US_BASE_URL + get_token_endpoint, status=200, response=get_token_response, ), AiohttpMockSession( method='post', url=US_BASE_URL + login_token_endpoint, status=200, response=login_token_response, ), AiohttpMockSession( method='post', url=US_BASE_URL + first_final_endpoint, status=200, response=orjson.dumps(final_response), ), ] self.run_in_loop(self.manager.get_devices) assert len(self.mock_api.mock_calls) == 5 # Includes __init__ call call_list = self.mock_api.mock_calls assert call_list[1].args[0] == 'post' assert call_list[1].kwargs['url'] == US_BASE_URL + first_final_endpoint assert call_list[2].args[0] == 'post' assert call_list[2].kwargs['url'] == US_BASE_URL + get_token_endpoint assert call_list[3].args[0] == 'post' assert call_list[3].kwargs['url'] == US_BASE_URL + login_token_endpoint assert call_list[4].args[0] == 'post' assert call_list[4].kwargs['url'] == US_BASE_URL + first_final_endpoint webdjoe-pyvesync-eb8cecb/src/tests/utils.py000066400000000000000000000254261507433633000213240ustar00rootroot00000000000000"""Common base class for tests and Default values. Routine Listings ---------------- TestBase: class Base class for tests to start mock & instantiat VS object parse_args: function Parse arguments from mock API call assert_test: function Test pyvesync API calls against existing API """ from itertools import zip_longest import logging from pathlib import Path from typing import Any import yaml import orjson from mashumaro.mixins.orjson import DataClassORJSONMixin from defaults import CALL_API_ARGS, ID_KEYS, API_DEFAULTS logger = logging.getLogger(__name__) _SENTINEL = object() class YAMLWriter: """Read and Write API request data to YAML files. Arguments --------- module : str name of module that is being tested dev_type : str device type being tested Attributes ---------- self.file_path : Path Path to YAML directory, default to API dir in tests folder self.file : Path Path to YAML file based on device type and module self.existings_yaml : dict Existing YAML data read to dict object self._existing_api : dict, optional Existing data dict of a specific API call Methods ------- self.existing_api() Return existing data dict of a specific API call or None self.write_api(api, data, overwrite=False) Writes data to YAML file for a specific API call, set overwrite=True to overwrite existing data """ def __init__(self, module, dev_type): """Init the YAMLWriter class. Arguments ---------- module : str name of module that is being tested dev_type : str device type being tested """ self.file_path = self._get_path(module) self.file = Path.joinpath(self.file_path, dev_type + '.yaml') self.new_file = self._new_file() self.existing_yaml = self._get_existing_yaml() self._existing_api = None @staticmethod 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) -> 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) -> 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) -> bool: """Check YAML file for existing data for API call. Arguments ---------- method : str Name of method being tested Returns ------- dict or None Existing data for API call or None """ if self.existing_yaml is not None: current_dict = self.existing_yaml.get(method) self._existing_api = current_dict if current_dict is not None: logger.debug(f'API call {method} already exists in {self.file}') return True return False def write_api(self, method, yaml_dict, overwrite=False): """Write API data to YAML file. Arguments ---------- method : str Name of method being tested yaml_dict : dict Data to write to YAML file overwrite : bool, optional Overwrite existing data, default to False """ if self.existing_yaml is not None: current_dict = self.existing_yaml.get(method) if current_dict is not None and overwrite is False: logger.debug(f'API call {method} already exists in {self.file}') return self.existing_yaml[method] = yaml_dict else: self.existing_yaml = {method: yaml_dict} with open(self.file, 'w', encoding='utf-8') as f: yaml.dump(self.existing_yaml, f, encoding='utf-8') def api_scrub(api_dict, device_type=None): """Recursive function to scrub all sensitive data from API call. Arguments ---------- api_dict : dict API call data to scrub device_type : str, optional Device type to use for default values Returns ------- dict Scrubbed API call data """ def id_cleaner(key, value): if key.upper() in ID_KEYS: return f"{device_type or 'UNKNOWN'}-{key.upper()}" if key in API_DEFAULTS: return API_DEFAULTS[key] return value def nested_dict_iter(nested, mapper, last_key=None): if isinstance(nested, dict): if nested.get('deviceType') is not None: nonlocal device_type device_type = nested['deviceType'] out = {} for key, value in nested.items(): out[key] = nested_dict_iter(value, mapper, key) return out if isinstance(nested, list): return [nested_dict_iter(el, mapper, last_key) for el in nested] if not last_key: return nested return mapper(last_key, nested) return nested_dict_iter(api_dict, id_cleaner) def parse_args(mock_api): """Parse arguments from mock API call. Arguments ---------- mock_api : mock Mock object used to path call_api() method Returns ------- dict dictionary of all call_api() arguments """ call_args = mock_api.call_args.args call_kwargs = mock_api.call_args.kwargs all_kwargs = dict(zip(CALL_API_ARGS, call_args)) all_kwargs.update(call_kwargs) if isinstance(all_kwargs.get("json_object"), DataClassORJSONMixin): all_kwargs["json_object"] = orjson.loads(all_kwargs["json_object"].to_json()) elif isinstance(all_kwargs.get("json_object"), dict): all_kwargs["json_object"] = orjson.loads(orjson.dumps(all_kwargs["json_object"])) return all_kwargs 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 ---------- test_func : method Method that is being tested all_kwargs : dict 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 ONLY USE FOR CHANGING API, default to False. Must be set with `write_api=True` Returns ------- bool True if test passes, False if test fails Asserts ------- Asserts that the API call data matches the expected data """ if all_kwargs.get('json_object') is not None: all_kwargs['json_object'] = api_scrub(all_kwargs['json_object'], dev_type) if all_kwargs.get('headers') is not None: all_kwargs['headers'] = api_scrub(all_kwargs['headers'], dev_type) mod = test_func.__self__.__module__.split(".")[-1] if dev_type is None: cls_name = test_func.__self__.__class__.__name__ else: cls_name = dev_type method_name = test_func.__name__ writer = YAMLWriter(mod, cls_name) 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) return True else: logger.debug("Not writing API data for %s %s %s", mod, cls_name, method_name) if writer._existing_api is None: return False assert writer._existing_api == all_kwargs, dicts_equal(writer._existing_api, all_kwargs) return True def deep_diff(a: Any, b: Any, path: str = ""): """Yield dicts describing differences between two (possibly nested) values.""" if type(a) is not type(b): yield { "path": path, "change": "type", "from": a, "to": b, "from_type": type(a).__name__, "to_type": type(b).__name__, } return if isinstance(a, dict): keys = set(a) | set(b) for k in sorted(keys, key=str): p = f"{path}.{k}" if path else str(k) if k not in b: yield {"path": p, "change": "removed", "value": a[k]} elif k not in a: yield {"path": p, "change": "added", "value": b[k]} else: yield from deep_diff(a[k], b[k], p) elif isinstance(a, (list, tuple)): for i, (va, vb) in enumerate(zip_longest(a, b, fillvalue=_SENTINEL)): p = f"{path}[{i}]" if va is _SENTINEL: yield {"path": p, "change": "added", "value": vb} elif vb is _SENTINEL: yield {"path": p, "change": "removed", "value": va} else: yield from deep_diff(va, vb, p) elif isinstance(a, set): if a != b: # repr-sort to avoid TypeError on mixed-type sets added = sorted(b - a, key=repr) removed = sorted(a - b, key=repr) yield {"path": path, "change": "set", "added": added, "removed": removed} elif a != b: yield {"path": path, "change": "modified", "from": a, "to": b} def format_diffs(diffs) -> str: sym = {"added": "+", "removed": "-", "modified": "~", "type": "±", "set": "∈"} out = [] for d in diffs: p = d["path"] or "" c = d["change"] if c in ("added", "removed"): out.append(f"{sym[c]} {p}: {d['value']!r}") elif c == "modified": out.append(f"{sym[c]} {p}: {d['from']!r} -> {d['to']!r}") elif c == "type": out.append(f"{sym[c]} {p}: {d['from_type']} -> {d['to_type']} " f"({d['from']!r} -> {d['to']!r})") elif c == "set": out.append(f"{sym[c]} {p}: +{d['added']!r} -{d['removed']!r}") return "\n".join(out) def dicts_equal(a: dict, b: dict, *, show_diff: bool = True): """Return (equal_bool, diffs). Optionally print a readable diff.""" diffs = list(deep_diff(a, b)) if show_diff and diffs: # print(format_diffs(diffs)) return 'Differences found:\n' + format_diffs(diffs) return "No differences found" webdjoe-pyvesync-eb8cecb/src/tests/xtest_x_air_pur.py000066400000000000000000000136741507433633000234050ustar00rootroot00000000000000"""Levoit Air Purifier tests.""" import orjson from pyvesync.devices.vesyncpurifier import VeSyncAirBypass from pyvesync.devices.vesyncpurifier import VeSyncAir131 from pyvesync.utils.helpers import Helpers as Helpers import call_json import call_json_fans from base_test_cases import TestBase from defaults import TestDefaults LVPUR131S = 'LV-PUR131S' CORE200S = 'Core200S' DEV_LIST_DETAIL = call_json.DeviceList.device_list_item(LVPUR131S) CORE200S_DETAIL = call_json.DeviceList.device_list_item(CORE200S) CORRECT_LIST = call_json.DeviceList.device_list_response(LVPUR131S) CORRECT_DETAILS = call_json_fans.DETAILS_RESPONSES[LVPUR131S] BAD_LIST = call_json.DETAILS_BADCODE class TestVesyncAirPurifier(TestBase): """Air purifier tests.""" def test_airpur_conf(self): """Tests that 15A Outlet is instantiated properly.""" self.mock_api.return_value = CORRECT_LIST self.run_in_loop(self.manager.get_devices) fans = self.manager.fans assert len(fans) == 1 fan = fans[0] assert isinstance(fan, VeSyncAir131) assert fan.device_name == TestDefaults.name(LVPUR131S) assert fan.device_type == LVPUR131S assert fan.cid == TestDefaults.cid(LVPUR131S) assert fan.uuid == TestDefaults.uuid(LVPUR131S) def test_airpur_details(self): """Test Air Purifier get_details().""" resp_dict, status = CORRECT_DETAILS self.mock_api.return_value = orjson.dumps(resp_dict), status fan = VeSyncAir131(DEV_LIST_DETAIL, self.manager) self.run_in_loop(fan.get_details) dev_details = fan.details assert fan.device_status == 'on' assert isinstance(dev_details, dict) assert dev_details['active_time'] == 1 assert fan.filter_life == call_json_fans.FanDefaults.filter_life assert dev_details['screen_status'] == TestDefaults.str_toggle assert fan.mode == 'manual' 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' def test_airpur_details_fail(self): """Test Air Purifier get_details with Code>0.""" resp_dict, status = BAD_LIST self.mock_api.return_value = orjson.dumps(resp_dict), status fan = VeSyncAir131(DEV_LIST_DETAIL, self.manager) self.run_in_loop(fan.get_details) assert len(self.caplog.records) == 1 assert 'details' in self.caplog.text def test_airpur_onoff(self): """Test Air Purifier Device On/Off Methods.""" self.mock_api.return_value = (orjson.dumps({'code': 0}), 200) fan = VeSyncAir131(DEV_LIST_DETAIL, self.manager) head = Helpers.req_legacy_headers(self.manager) body = Helpers.req_body(self.manager, 'devicestatus') fan.device_status = 'off' body['status'] = 'on' body['uuid'] = fan.uuid on = self.run_in_loop(fan.turn_on) self.mock_api.assert_called_with( '/131airPurifier/v1/device/deviceStatus', 'put', json_object=body, headers=head) call_args = self.mock_api.call_args_list[0][0] assert call_args[0] == '/131airPurifier/v1/device/deviceStatus' assert call_args[1] == 'put' assert on fan.device_status = 'on' off = self.run_in_loop(fan.turn_off) body['status'] = 'off' self.mock_api.assert_called_with( '/131airPurifier/v1/device/deviceStatus', 'put', json_object=body, headers=head) assert off def test_airpur_onoff_fail(self): """Test Air Purifier On/Off Fail with Code>0.""" self.mock_api.return_value = (orjson.dumps({'code': 1}), 400) vsfan = VeSyncAir131(DEV_LIST_DETAIL, self.manager) assert not self.run_in_loop(vsfan.turn_on) assert not self.run_in_loop(vsfan.turn_off) def test_airpur_fanspeed(self): """Test changing fan speed of.""" self.mock_api.return_value = (orjson.dumps({'code': 0}), 200) fan = VeSyncAir131(DEV_LIST_DETAIL, self.manager) fan.mode = 'manual' fan.details['level'] = 1 b = self.run_in_loop(fan.set_fan_speed) assert fan.fan_level == 2 b = self.run_in_loop(fan.set_fan_speed) assert fan.fan_level == 3 b = self.run_in_loop(fan.set_fan_speed) assert fan.fan_level == 1 assert b b = self.run_in_loop(fan.set_fan_speed, 2) assert b assert fan.fan_level == 2 def test_mode_toggle(self): """Test changing modes on air purifier.""" self.mock_api.return_value = (orjson.dumps({'code': 0}), 200) fan = VeSyncAir131(DEV_LIST_DETAIL, self.manager) f = self.run_in_loop(fan.auto_mode) assert f assert fan.mode == 'auto' f = self.run_in_loop(fan.manual_mode) assert fan.mode == 'manual' assert f f = self.run_in_loop(fan.sleep_mode) assert fan.mode == 'sleep' assert f def test_airpur_set_timer(self): """Test timer function of Core*00S Purifiers.""" self.mock_api.return_value = ( orjson.dumps(call_json_fans.INNER_RESULT({'id': 1})), 200 ) fan = VeSyncAirBypass(CORE200S_DETAIL, self.manager) self.run_in_loop(fan.set_timer, 100) assert fan.timer is not None assert fan.timer.timer_duration == 100 assert fan.timer.done is False assert fan.timer.action == 'off' assert fan.timer.running is True def test_airpur_clear_timer(self): """Test clear_timer method for Core air purifiers.""" resp_dict, status = call_json_fans.FunctionResponses['Core200S'] self.mock_api.return_value = orjson.dumps(resp_dict), status fan = VeSyncAirBypass(CORE200S_DETAIL, self.manager) fan.timer = call_json_fans.FAN_TIMER self.run_in_loop(fan.clear_timer) assert fan.timer is None webdjoe-pyvesync-eb8cecb/src/tests/xtest_x_vesync_10a.py000066400000000000000000000165731507433633000237150ustar00rootroot00000000000000"""Test scripts for Etekcity 10A Outlets.""" import pytest import orjson from pyvesync.devices.vesyncoutlet import VeSyncOutlet10A from pyvesync.utils.helpers import Helpers as Helpers import call_json import call_json_outlets from base_test_cases import TestBase from defaults import TestDefaults OutletDefaults = call_json_outlets.OutletDefaults DEV_TYPE_US = 'ESW03-USA' DEV_TYPE_EU = 'ESW01-EU' DEV_LIST_DETAIL_EU = call_json.DeviceList.device_list_item(DEV_TYPE_EU) DEV_LIST_DETAIL_US = call_json.DeviceList.device_list_item(DEV_TYPE_US) CORRECT_10AUS_LIST = call_json.DeviceList.device_list_response(DEV_TYPE_US) CORRECT_10AEU_LIST = call_json.DeviceList.device_list_response(DEV_TYPE_EU) ENERGY_HISTORY = call_json_outlets.ENERGY_HISTORY CORRECT_10A_DETAILS = call_json_outlets.DETAILS_RESPONSES[DEV_TYPE_US] BAD_10A_LIST = call_json.DETAILS_BADCODE class TestVesync10ASwitch(TestBase): """Test class for 10A outlets.""" @pytest.mark.parametrize( 'mock_return, devtype', [(CORRECT_10AEU_LIST, DEV_TYPE_EU), (CORRECT_10AUS_LIST, DEV_TYPE_US)], ) def test_10a_conf(self, mock_return, devtype): """Tests that 10A US & EU Outlet is instantiated properly.""" resp_dict, status = mock_return self.mock_api.return_value = resp_dict, status self.run_in_loop(self.manager.get_devices) outlets = self.manager.outlets assert len(outlets) == 1 outlet = outlets[0] assert isinstance(outlet, VeSyncOutlet10A) assert outlet.device_name == TestDefaults.name(devtype) assert outlet.device_type == devtype assert outlet.cid == TestDefaults.cid(devtype) assert outlet.uuid == TestDefaults.uuid(devtype) def test_10a_details(self): """Test 10A get_details().""" resp_dict, status = CORRECT_10A_DETAILS self.mock_api.return_value = orjson.dumps(resp_dict), status outlet = VeSyncOutlet10A(DEV_LIST_DETAIL_US, self.manager) self.run_in_loop(outlet.get_details) dev_details = outlet.details assert outlet.device_status == 'on' assert isinstance(dev_details, dict) assert dev_details['active_time'] == TestDefaults.active_time assert dev_details['energy'] == OutletDefaults.energy assert dev_details['power'] == OutletDefaults.power assert dev_details['voltage'] == OutletDefaults.voltage assert outlet.power == OutletDefaults.power assert outlet.voltage == OutletDefaults.voltage def test_10a_details_fail(self): """Test 10A get_details with Code>0.""" resp_dict, status = BAD_10A_LIST self.mock_api.return_value = orjson.dumps(resp_dict), status out = VeSyncOutlet10A(DEV_LIST_DETAIL_EU, self.manager) self.run_in_loop(out.get_details) assert len(self.caplog.records) == 1 assert 'details' in self.caplog.text def test_10a_onoff(self): """Test 10A Device On/Off Methods.""" self.mock_api.return_value = (orjson.dumps({'code': 0}), 200) out = VeSyncOutlet10A(DEV_LIST_DETAIL_EU, self.manager) head = Helpers.req_legacy_headers(self.manager) body = Helpers.req_body(self.manager, 'devicestatus') body['status'] = 'on' body['uuid'] = out.uuid on = self.run_in_loop(out.turn_on) self.mock_api.assert_called_with( '/10a/v1/device/devicestatus', 'put', headers=head, json_object=body ) assert on off = self.run_in_loop(out.turn_off) body['status'] = 'off' self.mock_api.assert_called_with( '/10a/v1/device/devicestatus', 'put', headers=head, json_object=body ) assert off def test_10a_onoff_fail(self): """Test 10A On/Off Fail with Code>0.""" self.mock_api.return_value = (orjson.dumps({'code': 1}), 400) out = VeSyncOutlet10A(DEV_LIST_DETAIL_US, self.manager) assert not self.run_in_loop(out.turn_on) assert not self.run_in_loop(out.turn_off) def test_10a_weekly(self): """Test 10A get_weekly_energy.""" resp_dict, status = ENERGY_HISTORY self.mock_api.return_value = orjson.dumps(resp_dict), status out = VeSyncOutlet10A(DEV_LIST_DETAIL_EU, self.manager) self.run_in_loop(out.get_weekly_energy) body = Helpers.req_body(self.manager, 'energy_week') body['uuid'] = out.uuid self.mock_api.assert_called_with( '/10a/v1/device/energyweek', 'post', headers=Helpers.req_legacy_headers(self.manager), json_object=body, ) energy_dict = out.energy['week'] assert energy_dict['energy_consumption_of_today'] == 1 assert energy_dict['cost_per_kwh'] == 1 assert energy_dict['max_energy'] == 1 assert energy_dict['total_energy'] == 1 assert energy_dict['data'] == [1, 1] assert out.weekly_energy_total == 1 def test_10a_monthly(self): """Test 10A get_monthly_energy.""" resp_dict, status = ENERGY_HISTORY self.mock_api.return_value = orjson.dumps(resp_dict), status out = VeSyncOutlet10A(DEV_LIST_DETAIL_EU, self.manager) self.run_in_loop(out.get_monthly_energy) body = Helpers.req_body(self.manager, 'energy_month') body['uuid'] = out.uuid self.mock_api.assert_called_with( '/10a/v1/device/energymonth', 'post', headers=Helpers.req_legacy_headers(self.manager), json_object=body, ) energy_dict = out.energy['month'] assert energy_dict['energy_consumption_of_today'] == 1 assert energy_dict['cost_per_kwh'] == 1 assert energy_dict['max_energy'] == 1 assert energy_dict['total_energy'] == 1 assert energy_dict['data'] == [1, 1] assert out.monthly_energy_total == 1 def test_10a_yearly(self): """Test 10A get_yearly_energy.""" resp_dict, status = ENERGY_HISTORY self.mock_api.return_value = orjson.dumps(resp_dict), status out = VeSyncOutlet10A(DEV_LIST_DETAIL_US, self.manager) self.run_in_loop(out.get_yearly_energy) body = Helpers.req_body(self.manager, 'energy_year') body['uuid'] = out.uuid self.mock_api.assert_called_with( '/10a/v1/device/energyyear', 'post', headers=Helpers.req_legacy_headers(self.manager), json_object=body, ) energy_dict = out.energy['year'] assert energy_dict['energy_consumption_of_today'] == 1 assert energy_dict['cost_per_kwh'] == 1 assert energy_dict['max_energy'] == 1 assert energy_dict['total_energy'] == 1 assert energy_dict['data'] == [1, 1] assert out.yearly_energy_total == 1 def test_history_fail(self): """Test 15A energy failure.""" bad_history = {'code': 1} self.mock_api.return_value = (orjson.dumps(bad_history), 200) out = VeSyncOutlet10A(DEV_LIST_DETAIL_US, self.manager) self.run_in_loop(out.update_energy) assert len(self.caplog.records) == 1 assert 'weekly' in self.caplog.text self.caplog.clear() self.run_in_loop(out.get_monthly_energy) assert len(self.caplog.records) == 1 assert 'monthly' in self.caplog.text self.caplog.clear() self.run_in_loop(out.get_yearly_energy) assert len(self.caplog.records) == 1 assert 'yearly' in self.caplog.text webdjoe-pyvesync-eb8cecb/src/tests/xtest_x_vesync_15a.py000066400000000000000000000203101507433633000237020ustar00rootroot00000000000000import orjson from pyvesync.devices.vesyncoutlet import VeSyncOutlet15A from pyvesync.utils.helpers import Helpers as Helpers import call_json import base_test_cases import call_json_outlets DEVICE_TYPE = 'ESW15-USA' DEV_LIST_DETAIL = call_json.DeviceList.device_list_item(DEVICE_TYPE) CORRECT_15A_LIST = call_json.DeviceList.device_list_response(DEVICE_TYPE) ENERGY_HISTORY = call_json_outlets.ENERGY_HISTORY CORRECT_15A_DETAILS = call_json_outlets.DETAILS_RESPONSES[DEVICE_TYPE] BAD_15A_LIST = call_json.DETAILS_BADCODE class TestVeSyncSwitch(base_test_cases.TestBase): def test_15aswitch_conf(self): """Tests that 15A Outlet is instantiated properly""" self.mock_api.return_value = CORRECT_15A_LIST self.run_in_loop(self.manager.get_devices) outlets = self.manager.outlets assert len(outlets) == 1 vswitch15a = outlets[0] assert isinstance(vswitch15a, VeSyncOutlet15A) assert vswitch15a.device_name == call_json.TestDefaults.name(DEVICE_TYPE) assert vswitch15a.device_type == DEVICE_TYPE assert vswitch15a.cid == call_json.TestDefaults.cid(DEVICE_TYPE) assert vswitch15a.uuid == call_json.TestDefaults.uuid(DEVICE_TYPE) def test_15a_details(self): """Test 15A get_details() """ resp_dict, status_code = CORRECT_15A_DETAILS self.mock_api.return_value = orjson.dumps(resp_dict), status_code vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.manager) self.run_in_loop(vswitch15a.get_details) dev_details = vswitch15a.details assert vswitch15a.device_status == 'on' assert isinstance(dev_details, dict) assert dev_details['active_time'] == 1 assert dev_details['energy'] == 1 assert dev_details['power'] == '1' assert dev_details['voltage'] == '1' assert vswitch15a.power == 1 assert vswitch15a.voltage == 1 assert vswitch15a.active_time == 1 assert vswitch15a.energy_today == 1 class TestVesync15ASwitch(base_test_cases.TestBase): def test_15aswitch_conf(self): """Tests that 15A Outlet is instantiated properly""" self.mock_api.return_value = CORRECT_15A_LIST self.run_in_loop(self.manager.get_devices) outlets = self.manager.outlets assert len(outlets) == 1 vswitch15a = outlets[0] assert isinstance(vswitch15a, VeSyncOutlet15A) assert vswitch15a.device_name == call_json.TestDefaults.name(DEVICE_TYPE) assert vswitch15a.device_type == DEVICE_TYPE assert vswitch15a.cid == call_json.TestDefaults.cid(DEVICE_TYPE) assert vswitch15a.uuid == call_json.TestDefaults.uuid(DEVICE_TYPE) def test_15a_details_fail(self): """Test 15A get_details with Code>0""" resp_dict, status_code = BAD_15A_LIST self.mock_api.return_value = orjson.dumps(resp_dict), status_code vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.manager) self.run_in_loop(vswitch15a.get_details) assert len(self.caplog.records) == 1 assert 'details' in self.caplog.text def NO_test_15a_no_details(self): """Test 15A details return with no details and code=0""" # Removed test - will not be needed with API response validation bad_15a_details = {'code': 0, 'deviceStatus': 'on'} self.mock_api.return_value = (orjson.dumps(bad_15a_details), 200) vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.manager) self.run_in_loop(vswitch15a.get_details) assert len(self.caplog.records) == 1 def test_15a_onoff(self): """Test 15A Device On/Off Methods""" self.mock_api.return_value = (orjson.dumps({'code': 0}), 200) vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.manager) head = Helpers.req_legacy_headers(self.manager) body = Helpers.req_body(self.manager, 'devicestatus') body['status'] = 'on' body['uuid'] = vswitch15a.uuid on = self.run_in_loop(vswitch15a.turn_on) self.mock_api.assert_called_with( '/15a/v1/device/devicestatus', 'put', headers=head, json_object=body ) assert on off = self.run_in_loop(vswitch15a.turn_off) body['status'] = 'off' self.mock_api.assert_called_with( '/15a/v1/device/devicestatus', 'put', headers=head, json_object=body ) assert off def test_15a_onoff_fail(self): """Test 15A On/Off Fail with Code>0""" self.mock_api.return_value = (orjson.dumps({'code': 1}), 400) vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.manager) assert not self.run_in_loop(vswitch15a.turn_on) assert not self.run_in_loop(vswitch15a.turn_off) def test_15a_weekly(self): """Test 15A get_weekly_energy""" resp_dict, status = ENERGY_HISTORY self.mock_api.return_value = orjson.dumps(resp_dict), status vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.manager) self.run_in_loop(vswitch15a.get_weekly_energy) body = Helpers.req_body(self.manager, 'energy_week') body['uuid'] = vswitch15a.uuid self.mock_api.assert_called_with( '/15a/v1/device/energyweek', 'post', headers=Helpers.req_legacy_headers(self.manager), json_object=body, ) energy_dict = vswitch15a.energy['week'] assert energy_dict['energy_consumption_of_today'] == 1 assert energy_dict['cost_per_kwh'] == 1 assert energy_dict['max_energy'] == 1 assert energy_dict['total_energy'] == 1 assert energy_dict['data'] == [1, 1] assert vswitch15a.weekly_energy_total == 1 def test_15a_monthly(self): """Test 15A get_monthly_energy""" resp_dict, status = ENERGY_HISTORY self.mock_api.return_value = orjson.dumps(resp_dict), status vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.manager) self.run_in_loop(vswitch15a.get_monthly_energy) body = Helpers.req_body(self.manager, 'energy_month') body['uuid'] = vswitch15a.uuid self.mock_api.assert_called_with( '/15a/v1/device/energymonth', 'post', headers=Helpers.req_legacy_headers(self.manager), json_object=body, ) energy_dict = vswitch15a.energy['month'] assert energy_dict['energy_consumption_of_today'] == 1 assert energy_dict['cost_per_kwh'] == 1 assert energy_dict['max_energy'] == 1 assert energy_dict['total_energy'] == 1 assert energy_dict['data'] == [1, 1] assert vswitch15a.monthly_energy_total == 1 def test_15a_yearly(self): """Test 15A get_yearly_energy""" resp_dict, status = ENERGY_HISTORY self.mock_api.return_value = orjson.dumps(resp_dict), status vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.manager) self.run_in_loop(vswitch15a.get_yearly_energy) body = Helpers.req_body(self.manager, 'energy_year') body['uuid'] = vswitch15a.uuid self.mock_api.assert_called_with( '/15a/v1/device/energyyear', 'post', headers=Helpers.req_legacy_headers(self.manager), json_object=body, ) energy_dict = vswitch15a.energy['year'] assert energy_dict['energy_consumption_of_today'] == 1 assert energy_dict['cost_per_kwh'] == 1 assert energy_dict['max_energy'] == 1 assert energy_dict['total_energy'] == 1 assert energy_dict['data'] == [1, 1] assert vswitch15a.yearly_energy_total == 1 def test_history_fail(self): """Test 15A energy failure""" bad_history = {'code': 1} self.mock_api.return_value = (orjson.dumps(bad_history), 200) vswitch15a = VeSyncOutlet15A(DEV_LIST_DETAIL, self.manager) self.run_in_loop(vswitch15a.update_energy) assert len(self.caplog.records) == 1 assert 'weekly' in self.caplog.text self.caplog.clear() self.run_in_loop(vswitch15a.get_monthly_energy) assert len(self.caplog.records) == 1 assert 'monthly' in self.caplog.text self.caplog.clear() self.run_in_loop(vswitch15a.get_yearly_energy) assert len(self.caplog.records) == 1 assert 'yearly' in self.caplog.text webdjoe-pyvesync-eb8cecb/src/tests/xtest_x_vesync_7aswitch.py000066400000000000000000000160121507433633000250510ustar00rootroot00000000000000"""Etekcity 7A Outlet tests.""" import orjson from pyvesync.devices.vesyncoutlet import VeSyncOutlet7A from pyvesync.utils.helpers import Helpers as Helpers import call_json import call_json_outlets from base_test_cases import TestBase DEVICE_TYPE = 'wifi-switch-1.3' DEV_LIST_DETAIL = call_json.DeviceList.device_list_item(DEVICE_TYPE) CORRECT_7A_LIST = call_json.DeviceList.device_list_response(DEVICE_TYPE) CORRECT_7A_DETAILS = call_json_outlets.DETAILS_RESPONSES[DEVICE_TYPE] ENERGY_HISTORY = call_json_outlets.ENERGY_HISTORY DEVICE_TYPE = 'wifi-switch-1.3' CALL_LIST = [ 'turn_on', 'turn_off', 'update' ] class TestVesync7ASwitch(TestBase): """Test 7A outlet API.""" def test_7aswitch_conf(self): """Test inizialization of 7A outlet.""" self.mock_api.return_value = CORRECT_7A_LIST self.run_in_loop(self.manager.get_devices) outlets = self.manager.outlets assert len(outlets) == 1 vswitch7a = outlets[0] assert isinstance(vswitch7a, VeSyncOutlet7A) assert vswitch7a.device_name == call_json.TestDefaults.name(DEVICE_TYPE) assert vswitch7a.device_type == DEVICE_TYPE assert vswitch7a.cid == call_json.TestDefaults.cid(DEVICE_TYPE) assert vswitch7a.is_on def test_7a_details(self): """Test get_details() method for 7A outlet.""" resp_dict, status = CORRECT_7A_DETAILS self.mock_api.return_value = orjson.dumps(resp_dict), status vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.manager) self.run_in_loop(vswitch7a.get_details) dev_details = vswitch7a.details assert vswitch7a.device_status == 'on' assert isinstance(dev_details, dict) assert dev_details['active_time'] == 1 assert dev_details['energy'] == 1 assert vswitch7a.power == 1 assert vswitch7a.voltage == 1 def NO_test_7a_no_devstatus(self): """Test 7A outlet details response with no device status key.""" # Remove test for invalid key - will be addressed by Validation bad_7a_details = { 'deviceImg': '', 'activeTime': 1, 'energy': 1, 'power': '1A:1A', 'voltage': '1A:1A', } self.mock_api.return_value = (orjson.dumps(bad_7a_details), 200) vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.manager) self.run_in_loop(vswitch7a.get_details) assert len(self.caplog.records) == 1 assert 'details' in self.caplog.text def NO_test_7a_no_details(self): """Test 7A outlet details response with unknown keys.""" # Remove test for invalid key - will be addressed by Validation bad_7a_details = {'wrongdetails': 'on'} self.mock_api.return_value = (orjson.dumps(bad_7a_details), 200) vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.manager) self.run_in_loop(vswitch7a.get_details) assert len(self.caplog.records) == 1 def test_7a_onoff(self): """Test 7A outlet on/off methods.""" self.mock_api.return_value = (None, 200) vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.manager) on = self.run_in_loop(vswitch7a.turn_on) head = Helpers.req_legacy_headers(self.manager) self.mock_api.assert_called_with( '/v1/wifi-switch-1.3/' + vswitch7a.cid + '/status/on', 'put', headers=head ) assert on off = self.run_in_loop(vswitch7a.turn_off) self.mock_api.assert_called_with( '/v1/wifi-switch-1.3/' + vswitch7a.cid + '/status/off', 'put', headers=head ) assert off def test_7a_onoff_fail(self): """Test 7A outlet on/off methods that fail.""" self.mock_api.return_value = (None, 400) vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.manager) assert not self.run_in_loop(vswitch7a.turn_on) assert not self.run_in_loop(vswitch7a.turn_off) def test_7a_weekly(self): """Test 7A outlet weekly energy API call and energy dict.""" resp_dict, status = ENERGY_HISTORY self.mock_api.return_value = orjson.dumps(resp_dict), status vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.manager) self.run_in_loop(vswitch7a.get_weekly_energy) self.mock_api.assert_called_with( '/v1/device/' + vswitch7a.cid + '/energy/week', 'get', headers=Helpers.req_legacy_headers(self.manager), ) energy_dict = vswitch7a.energy['week'] assert energy_dict['energy_consumption_of_today'] == 1 assert energy_dict['cost_per_kwh'] == 1 assert energy_dict['max_energy'] == 1 assert energy_dict['total_energy'] == 1 assert energy_dict['data'] == [1, 1] def test_7a_monthly(self): """Test 7A outlet monthly energy API call and energy dict.""" resp_dict, status = ENERGY_HISTORY self.mock_api.return_value = orjson.dumps(resp_dict), status vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.manager) self.run_in_loop(vswitch7a.get_monthly_energy) self.mock_api.assert_called_with( '/v1/device/' + vswitch7a.cid + '/energy/month', 'get', headers=Helpers.req_legacy_headers(self.manager), ) energy_dict = vswitch7a.energy['month'] assert energy_dict['energy_consumption_of_today'] == 1 assert energy_dict['cost_per_kwh'] == 1 assert energy_dict['max_energy'] == 1 assert energy_dict['total_energy'] == 1 assert energy_dict['data'] == [1, 1] def test_7a_yearly(self): """Test 7A outlet yearly energy API call and energy dict.""" resp_dict, status = ENERGY_HISTORY self.mock_api.return_value = orjson.dumps(resp_dict), status vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.manager) self.run_in_loop(vswitch7a.get_yearly_energy) self.mock_api.assert_called_with( '/v1/device/' + vswitch7a.cid + '/energy/year', 'get', headers=Helpers.req_legacy_headers(self.manager), ) energy_dict = vswitch7a.energy['year'] assert energy_dict['energy_consumption_of_today'] == 1 assert energy_dict['cost_per_kwh'] == 1 assert energy_dict['max_energy'] == 1 assert energy_dict['total_energy'] == 1 assert energy_dict['data'] == [1, 1] def test_history_fail(self): """Test handling of energy update failure.""" bad_history = {'code': 1} self.mock_api.return_value = (orjson.dumps(bad_history), 200) vswitch7a = VeSyncOutlet7A(DEV_LIST_DETAIL, self.manager) self.run_in_loop(vswitch7a.update_energy) assert len(self.caplog.records) == 1 assert 'weekly' in self.caplog.text self.caplog.clear() self.run_in_loop(vswitch7a.get_monthly_energy) assert len(self.caplog.records) == 1 assert 'monthly' in self.caplog.text self.caplog.clear() self.run_in_loop(vswitch7a.get_yearly_energy) assert len(self.caplog.records) == 1 assert 'yearly' in self.caplog.text webdjoe-pyvesync-eb8cecb/src/tests/xtest_x_vesync_bsdgo1.py000066400000000000000000000122501507433633000244770ustar00rootroot00000000000000# """Test scripts for BSDGO1 Outlets.""" # from __future__ import annotations # from typing import TYPE_CHECKING # import orjson # from pyvesync.devices.vesyncoutlet import VeSyncOutletBSDGO1 # from pyvesync.utils.helpers import Helpers as Helpers # from pyvesync.models.vesync_models import ResponseDeviceDetailsModel # from pyvesync.device_map import get_outlet # import call_json # import call_json_outlets # from base_test_cases import TestBase # from defaults import Defaults # if TYPE_CHECKING: # from pyvesync.device_map import OutletMap # DEVICE_TYPE = 'BSDOG01' # if get_outlet(DEVICE_TYPE) is not None: # DEVICE_MAP = get_outlet(DEVICE_TYPE) # DEV_LIST_DETAIL = call_json.DeviceList.device_list_item(DEVICE_MAP) # CORRECT_BSDGO1_DETAILS = call_json_outlets.DETAILS_RESPONSES[DEVICE_TYPE] # BAD_BSDGO1_LIST = call_json.DETAILS_BADCODE # class TestVeSyncBSDGO1Switch(TestBase): # """Test BSDGO1 outlet API.""" # def device_map(self) -> OutletMap: # outlet_map = get_outlet(DEVICE_TYPE) # if outlet_map is None: # raise ValueError(f'No device map found for {DEVICE_TYPE}') # return outlet_map # def device_list_model(self) -> ResponseDeviceDetailsModel: # return ResponseDeviceDetailsModel.from_dict(call_json.DeviceList.device_list_item(self.device_map())) # def test_bsdgo1_conf(self): # """Test initialization of BSDGO1 outlet.""" # self.mock_api.return_value = call_json.DeviceList.device_list_response(DEVICE_TYPE) # self.run_in_loop(self.manager.get_devices) # outlets = self.manager.devices.outlets # assert len(outlets) == 1 # bsdgo1_outlet = outlets[0] # assert isinstance(bsdgo1_outlet, VeSyncOutletBSDGO1) # assert bsdgo1_outlet.device_name == call_json.Defaults.name(DEVICE_TYPE) # assert bsdgo1_outlet.device_type == DEVICE_TYPE # assert bsdgo1_outlet.cid == call_json.Defaults.cid(DEVICE_TYPE) # assert bsdgo1_outlet.uuid == call_json.Defaults.uuid(DEVICE_TYPE) # def test_bsdgo1_details(self): # """Test BSDGO1 get_details().""" # resp_dict, status = self.device_list_model(), 200 # self.mock_api.return_value = orjson.dumps(resp_dict), status # bsdgo1_outlet = VeSyncOutletBSDGO1( # self.device_list_model(), # self.manager, # self.device_map(), # ) # self.run_in_loop(bsdgo1_outlet.get_details) # response = CORRECT_BSDGO1_DETAILS[0] # result = response.get('result', {}) # expected_status = 'on' if result.get('powerSwitch_1') == 1 else 'off' # assert bsdgo1_outlet.state.device_status == expected_status # assert result.get('active_time') == Defaults.active_time # assert result.get('connectionStatus') == 'online' # def test_bsdgo1_details_fail(self): # """Test BSDGO1 get_details with bad response.""" # resp_dict, status = BAD_BSDGO1_LIST # self.mock_api.return_value = orjson.dumps(resp_dict), status # bsdgo1_outlet = VeSyncOutletBSDGO1( # self.device_list_model(), # self.manager, # self.device_map(), # ) # self.run_in_loop(bsdgo1_outlet.get_details) # assert len(self.caplog.records) == 1 # assert 'details' in self.caplog.text # def test_bsdgo1_onoff(self): # """Test BSDGO1 Device On/Off Methods.""" # self.mock_api.return_value = (orjson.dumps({'code': 0}), 200) # bsdgo1_outlet = VeSyncOutletBSDGO1( # self.device_list_model(), # self.manager, # self.device_map(), # ) # head = Helpers.req_header_bypass() # body = Helpers.req_body(self.manager, 'bypassV2') # body['cid'] = bsdgo1_outlet.cid # body['configModule'] = bsdgo1_outlet.config_module # # Test turn_on # body['payload'] = { # 'data': {'powerSwitch_1': 1}, # 'method': 'setProperty', # 'source': 'APP' # } # on = self.run_in_loop(bsdgo1_outlet.turn_on) # self.mock_api.assert_called_with( # '/cloud/v2/deviceManaged/bypassV2', # 'post', # headers=head, # json_object=body # ) # assert on # # Test turn_off # body['payload'] = { # 'data': {'powerSwitch_1': 0}, # 'method': 'setProperty', # 'source': 'APP' # } # off = self.run_in_loop(bsdgo1_outlet.turn_off) # self.mock_api.assert_called_with( # '/cloud/v2/deviceManaged/bypassV2', # 'post', # headers=head, # json_object=body # ) # assert off # def test_bsdgo1_onoff_fail(self): # """Test BSDGO1 On/Off Fail with bad response.""" # resp_dict, status = BAD_BSDGO1_LIST # self.mock_api.return_value = orjson.dumps(resp_dict), status # bsdgo1_outlet = VeSyncOutletBSDGO1( # self.device_list_model(), # self.manager, # self.device_map(), # ) # assert not self.run_in_loop(bsdgo1_outlet.turn_on) # assert not self.run_in_loop(bsdgo1_outlet.turn_off) webdjoe-pyvesync-eb8cecb/src/tests/xtest_x_vesync_bulbs.py000066400000000000000000000343101507433633000244300ustar00rootroot00000000000000from collections import namedtuple import orjson from pyvesync.utils.colors import RGB from pyvesync.devices.vesyncbulb import (VeSyncBulbESL100, VeSyncBulbESL100CW, VeSyncBulbESL100MC, VeSyncBulbValcenoA19MC) import call_json import call_json_bulbs from base_test_cases import TestBase DEV_LIST = call_json.DeviceList.device_list_response('ESL100') DEV_LIST_DETAIL = call_json.DeviceList.device_list_item('ESL100') DEV_LIST_DETAIL_CW = call_json.DeviceList.device_list_item('ESL100CW') DEV_LIST_CW = call_json.DeviceList.device_list_response('ESL100CW') DEV_LIST_DETAIL_MC = call_json.DeviceList.device_list_item('ESL100MC') DEV_LIST_MC = call_json.DeviceList.device_list_response('ESL100MC') DEV_LIST_DETAIL_VALCENO = call_json.DeviceList.device_list_item('XYD0001') DEV_LIST_VALCENO = call_json.DeviceList.device_list_response('XYD0001') DEVICE_DETAILS = call_json_bulbs.DETAILS_RESPONSES['ESL100'] DEVICE_DETAILS_CW = call_json_bulbs.DETAILS_RESPONSES['ESL100CW'] DEFAULTS = call_json.TestDefaults SUCCESS_RETURN = (orjson.dumps({'code': 0}), 200) class TestVeSyncBulbESL100(TestBase): """Tests for VeSync dimmable bulb.""" device_type = 'ESL100' def test_esl100_conf(self): """Tests that Wall Switch is instantiated properly.""" self.mock_api.return_value = DEV_LIST self.run_in_loop(self.manager.get_devices) bulbs = self.manager.bulbs assert len(bulbs) == 1 bulb = bulbs[0] assert isinstance(bulb, VeSyncBulbESL100) assert bulb.device_name == call_json.TestDefaults.name(self.device_type) assert bulb.device_type == self.device_type assert bulb.cid == call_json.TestDefaults.cid(self.device_type) assert bulb.uuid == call_json.TestDefaults.uuid(self.device_type) def test_esl100_details(self): """Test WS get_details().""" if callable(DEVICE_DETAILS): resp_dict, status = DEVICE_DETAILS() else: resp_dict, status = DEVICE_DETAILS self.mock_api.return_value = orjson.dumps(resp_dict), status bulb = VeSyncBulbESL100(DEV_LIST_DETAIL, self.manager) self.run_in_loop(bulb.get_details) dev_details = bulb.details assert bulb.device_status == 'on' assert isinstance(dev_details, dict) assert bulb.connection_status == 'online' def test_esl100_no_details(self): """Test no device details for disconnected bulb.""" self.mock_api.return_value = (orjson.dumps({'code': 5}), 200) bulb = VeSyncBulbESL100(DEV_LIST_DETAIL, self.manager) self.run_in_loop(bulb.update) assert len(self.caplog.records) == 1 def test_esl100_onoff(self): """Test power toggle for ESL100 bulb.""" self.mock_api.return_value = (orjson.dumps({'code': 0}), 200) bulb = VeSyncBulbESL100(DEV_LIST_DETAIL, self.manager) assert self.run_in_loop(bulb.turn_off) assert self.run_in_loop(bulb.turn_on) def test_brightness(self): self.mock_api.return_value = (orjson.dumps({'code': 0}), 200) bulb = VeSyncBulbESL100(DEV_LIST_DETAIL, self.manager) assert self.run_in_loop(bulb.set_brightness, 50) assert self.run_in_loop(bulb.turn_off) assert self.run_in_loop(bulb.set_brightness, 50) assert bulb.device_status == 'on' def test_invalid_brightness(self): self.mock_api.return_value = (orjson.dumps({'code': 0}), 200) bulb = VeSyncBulbESL100(DEV_LIST_DETAIL, self.manager) assert self.run_in_loop(bulb.set_brightness, 5000) assert bulb.brightness == 100 def test_features(self): bulb = VeSyncBulbESL100(DEV_LIST_DETAIL, self.manager) assert bulb.dimmable_feature assert not bulb.color_temp_feature assert not bulb.rgb_shift_feature class TestVeSyncBulbESL100CW(TestBase): """Tests for VeSync dimmable bulb.""" device_type = 'ESL100CW' def test_esl100cw_conf(self): """Tests that Wall Switch is instantiated properly.""" if callable(DEV_LIST_CW): resp_dict, status = DEV_LIST_CW() else: resp_dict, status = DEV_LIST_CW if isinstance(resp_dict, str): resp_str = resp_dict.encode('utf-8') elif isinstance(resp_dict, dict): resp_str = orjson.dumps(resp_dict) elif isinstance(resp_dict, bytes): resp_str = resp_dict self.mock_api.return_value = resp_str, status self.run_in_loop(self.manager.get_devices) bulbs = self.manager.bulbs assert len(bulbs) == 1 bulb = bulbs[0] assert isinstance(bulb, VeSyncBulbESL100CW) assert bulb.device_name == DEFAULTS.name(self.device_type) assert bulb.device_type == self.device_type assert bulb.cid == DEFAULTS.cid(self.device_type) assert bulb.uuid == DEFAULTS.uuid(self.device_type) def test_esl100cw_details(self): """Test WS get_details().""" if callable(DEVICE_DETAILS_CW): resp_dict, status = DEVICE_DETAILS_CW() else: resp_dict, status = DEVICE_DETAILS_CW if isinstance(resp_dict, str): resp_bytes = resp_dict.encode('utf-8') elif isinstance(resp_dict, dict): resp_bytes = orjson.dumps(resp_dict) elif isinstance(resp_dict, bytes): resp_bytes = resp_dict self.mock_api.return_value = resp_bytes, status bulb = VeSyncBulbESL100CW(DEV_LIST_DETAIL_CW, self.manager) self.run_in_loop(bulb.get_details) assert self.mock_api.r assert bulb.device_status == 'on' assert bulb.connection_status == 'online' def test_esl100cw_onoff(self): """Test power toggle for ESL100 bulb.""" self.mock_api.return_value = SUCCESS_RETURN bulb = VeSyncBulbESL100CW(DEV_LIST_DETAIL_CW, self.manager) assert self.run_in_loop(bulb.turn_off) assert bulb.device_status == 'off' assert self.run_in_loop(bulb.turn_on) assert bulb.device_status == 'on' def test_brightness(self): self.mock_api.return_value = SUCCESS_RETURN bulb = VeSyncBulbESL100CW(DEV_LIST_DETAIL_CW, self.manager) assert self.run_in_loop(bulb.set_brightness, 50) assert bulb.brightness == 50 assert self.run_in_loop(bulb.turn_off) assert self.run_in_loop(bulb.set_brightness, 50) assert bulb.device_status == 'on' def test_invalid_brightness(self): self.mock_api.return_value = SUCCESS_RETURN bulb = VeSyncBulbESL100CW(DEV_LIST_DETAIL_CW, self.manager) assert self.run_in_loop(bulb.set_brightness, 5000) assert bulb.brightness == 100 def test_color_temp(self): self.mock_api.return_value = SUCCESS_RETURN bulb = VeSyncBulbESL100CW(DEV_LIST_DETAIL_CW, self.manager) assert self.run_in_loop(bulb.set_color_temp, 50) assert bulb.color_temp_pct == 50 def test_features(self): bulb = VeSyncBulbESL100CW(DEV_LIST_DETAIL_CW, self.manager) assert bulb.dimmable_feature assert bulb.color_temp_feature assert not bulb.rgb_shift_feature class TestVeSyncBulbESL100MC(TestBase): """Tests for VeSync dimmable bulb.""" device_type = 'ESL100MC' def test_esl100mc_conf(self): """Tests that Wall Switch is instantiated properly.""" self.mock_api.return_value = DEV_LIST_MC self.run_in_loop(self.manager.get_devices) bulbs = self.manager.bulbs assert len(bulbs) == 1 bulb = bulbs[0] assert isinstance(bulb, VeSyncBulbESL100MC) assert bulb.device_name == DEFAULTS.name(self.device_type) assert bulb.device_type == self.device_type assert bulb.cid == DEFAULTS.cid(self.device_type) assert bulb.uuid == DEFAULTS.uuid(self.device_type) def test_esl100mc_details(self): """Test WS get_details().""" self.mock_api.return_value = call_json_bulbs.DETAILS_RESPONSES[self.device_type] bulb = VeSyncBulbESL100MC(DEV_LIST_DETAIL_MC, self.manager) self.run_in_loop(bulb.get_details) assert self.mock_api.r assert bulb.device_status == 'on' assert bulb.connection_status == 'online' def test_esl100mc_onoff(self): """Test power toggle for ESL100 bulb.""" self.mock_api.return_value = SUCCESS_RETURN bulb = VeSyncBulbESL100MC(DEV_LIST_DETAIL_MC, self.manager) assert self.run_in_loop(bulb.turn_off) assert bulb.device_status == 'off' assert self.run_in_loop(bulb.turn_on) assert bulb.device_status == 'on' def test_brightness(self): self.mock_api.return_value = SUCCESS_RETURN bulb = VeSyncBulbESL100MC(DEV_LIST_DETAIL_MC, self.manager) assert self.run_in_loop(bulb.set_brightness, 50) assert bulb.brightness == 50 assert self.run_in_loop(bulb.turn_off) assert self.run_in_loop(bulb.set_brightness, 50) assert bulb.device_status == 'on' def test_invalid_brightness(self): self.mock_api.return_value = SUCCESS_RETURN bulb = VeSyncBulbESL100MC(DEV_LIST_DETAIL_MC, self.manager) assert self.run_in_loop(bulb.set_brightness, 5000) assert bulb.brightness == 100 def test_color(self): self.mock_api.return_value = SUCCESS_RETURN bulb = VeSyncBulbESL100MC(DEV_LIST_DETAIL_MC, self.manager) assert self.run_in_loop(bulb.set_rgb, 50, 100, 150) assert bulb.color_rgb == namedtuple('rgb', 'red green blue')(50, 100, 150) def test_features(self): bulb = VeSyncBulbESL100MC(DEV_LIST_DETAIL_MC, self.manager) assert bulb.dimmable_feature assert not bulb.color_temp_feature assert bulb.rgb_shift_feature class TestVeSyncBulbValceno(TestBase): """Tests for VeSync Valceno bulb.""" device_type = 'XYD0001' def test_valceno_conf(self): """Tests that Valceno is instantiated properly.""" self.mock_api.return_value = DEV_LIST_VALCENO self.run_in_loop(self.manager.get_devices) bulbs = self.manager.bulbs assert len(bulbs) == 1 bulb = bulbs[0] assert isinstance(bulb, VeSyncBulbValcenoA19MC) assert bulb.device_name == DEFAULTS.name(self.device_type) assert bulb.device_type == self.device_type assert bulb.cid == DEFAULTS.cid(self.device_type) assert bulb.uuid == DEFAULTS.uuid(self.device_type) def test_valceno_details(self): """Test Valceno get_details().""" self.mock_api.return_value = DEV_LIST_VALCENO bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.manager) self.run_in_loop(bulb.get_details) assert self.mock_api.r assert bulb.device_status == 'on' assert bulb.connection_status == 'online' def test_valceno_onoff(self): """Test power toggle for Valceno MC bulb.""" self.mock_api.return_value = SUCCESS_RETURN bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.manager) assert self.run_in_loop(bulb.turn_off) assert bulb.device_status == 'off' assert self.run_in_loop(bulb.turn_on) assert bulb.device_status == 'on' def test_brightness(self): """Test brightness on Valceno.""" self.mock_api.return_value = SUCCESS_RETURN bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.manager) assert self.run_in_loop(bulb.set_brightness, 50) assert bulb.brightness == 50 assert self.run_in_loop(bulb.turn_off) assert self.run_in_loop(bulb.set_brightness, 50) assert bulb.device_status == 'on' def test_invalid_brightness(self): """Test invalid brightness on Valceno.""" self.mock_api.return_value = SUCCESS_RETURN bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.manager) assert not self.run_in_loop(bulb.set_brightness, 5000) def test_invalid_saturation(self): """Test invalid saturation on Valceno.""" self.mock_api.return_value = SUCCESS_RETURN bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.manager) assert self.run_in_loop(bulb.set_color_saturation, 100) body_dict = { "method": "setLightStatusV2", "source": "APP", "data": { "force": 1, "brightness": "", "colorTemp": "", "colorMode": "hsv", "hue": "", "saturation": 1000, "value": "" } } mock_call = self.mock_api.call_args[1]['json_object']['payload'] assert mock_call == body_dict def test_color(self): """Test set color on Valceno.""" self.mock_api.return_value = (orjson.dumps({ 'code': 0, 'msg': '', 'result': { 'code': 0, 'result': { "enabled": 'on', "colorMode": 'hsv', 'brightness': 100, 'hue': 5833, 'saturation': 6700, 'value': 59 } } }), 200) bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.manager) assert self.run_in_loop(bulb.set_rgb, 50, 100, 150) assert bulb.color_rgb == RGB(50, 100, 150) def test_hue(self): """Test hue on Valceno MC Bulb.""" self.mock_api.return_value = SUCCESS_RETURN bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.manager) self.run_in_loop(bulb.set_color_hue, 230.5) body_dict = { "method": "setLightStatusV2", "source": "APP", "data": { "force": 1, "brightness": "", "colorTemp": "", "colorMode": "hsv", "hue": 6403, "saturation": "", "value": "" } } mock_call = self.mock_api.call_args[1]['json_object']['payload'] assert mock_call == body_dict def test_features(self): bulb = VeSyncBulbValcenoA19MC(DEV_LIST_DETAIL_VALCENO, self.manager) assert bulb.dimmable_feature assert bulb.color_temp_feature assert bulb.rgb_shift_feature webdjoe-pyvesync-eb8cecb/src/tests/xtest_x_vesync_devices.py000066400000000000000000000305331507433633000247460ustar00rootroot00000000000000"""Test VeSync manager methods.""" import pytest import asyncio from unittest.mock import patch import pyvesync import logging import copy import time import orjson from itertools import chain from pyvesync.vesyncfan import * from pyvesync.devices.vesyncbulb import * from pyvesync.devices.vesyncoutlet import * from pyvesync.devices.vesyncpurifier import VeSyncAir131 from pyvesync.devices.vesyncswitch import * import call_json as json_vals from call_json_switches import SWITCHES_NUM from call_json_outlets import OUTLETS_NUM from call_json_fans import FANS_NUM from call_json_bulbs import BULBS_NUM BAD_DEV_LIST = { 'result': { 'total': 5, 'pageNo': 1, 'pageSize': 50, 'list': [{'NoConfigKeys': None}], }, 'code': 0, } class TestDeviceList(object): """Test getting and populating device lists.""" @pytest.fixture(scope='function') def api_mock(self, caplog): """Mock call_api and initialize VeSync object.""" self.mock_api_call = patch('pyvesync.vesync.VeSync.async_call_api') self.mock_api = self.mock_api_call.start() self.loop = asyncio.new_event_loop() self.mock_api.return_value.ok = True self.vesync_obj = pyvesync.vesync.VeSync('sam@mail.com', 'pass', debug=True) self.vesync_obj.enabled = True self.vesync_obj.token = 'sample_tk' self.vesync_obj.account_id = 'sample_id' self.vesync_obj.in_process = False caplog.set_level(logging.DEBUG) yield self.mock_api_call.stop() async def run_coro(self, coro): """Run a coroutine in the event loop.""" return await coro def run_in_loop(self, func, *args, **kwargs): """Run a function in the event loop.""" return self.loop.run_until_complete(self.run_coro(func(*args, **kwargs))) def test_device_api(self, caplog, api_mock): """Tests to ensure call_api is being called correctly.""" head = json_vals.DEFAULT_HEADER_BYPASS self.mock_api.return_value = (orjson.dumps({'V': 2}), 200) self.run_in_loop(self.vesync_obj.get_devices) call_list = self.mock_api.call_args_list call_p1 = call_list[0][0] call_p2 = call_list[0][1] assert call_p1[0] == '/cloud/v1/deviceManaged/devices' assert call_p1[1] == 'post' assert call_p2['headers'] == head assert self.vesync_obj.enabled @patch('pyvesync.vesyncoutlet.VeSyncOutlet7A') @patch('pyvesync.vesyncoutlet.VeSyncOutlet15A') @patch('pyvesync.vesyncoutlet.VeSyncOutlet10A') @patch('pyvesync.vesyncswitch.VeSyncWallSwitch') @patch('pyvesync.vesyncswitch.VeSyncDimmerSwitch') @patch('pyvesync.vesyncfan.VeSyncAir131') def test_getdevs_vsfact( self, air_patch, wsdim_patch, ws_patch, out10a_patch, out15a_patch, out7a_patch, api_mock ): """Test the get_devices, process_devices and VSFactory methods. Build list with device objects from details Test for all 6 known devices - 4 outlets, 2 switches, 1 fan. """ device_list = json_vals.DeviceList.device_list_response() self.mock_api.return_value = device_list self.run_in_loop(self.vesync_obj.get_devices) assert len(self.vesync_obj.outlets) == OUTLETS_NUM assert len(self.vesync_obj.switches) == SWITCHES_NUM assert len(self.vesync_obj.fans) == FANS_NUM assert len(self.vesync_obj.bulbs) == BULBS_NUM def test_lv600( self, api_mock ): """Test the get_devices, process_devices and VSFactory methods. Build list with device objects from details Test for all 6 known devices - 4 outlets, 2 switches, 1 fan. """ resp_dict, status = json_vals.DeviceList.FAN_TEST self.mock_api.return_value = orjson.dumps(resp_dict), status self.run_in_loop(self.vesync_obj.get_devices) assert len(self.vesync_obj.fans) == 3 def test_dual200s( self, api_mock ): """Test the get_devices, process_devices and VSFactory methods. Build list with device objects from details Test for all 6 known devices - 4 outlets, 2 switches, 1 fan. """ self.mock_api.return_value = json_vals.DeviceList.device_list_response('Dual200S') self.vesync_obj.debug = True self.run_in_loop(self.vesync_obj.get_devices) assert len(self.vesync_obj.fans) == 1 def test_getdevs_code(self, caplog, api_mock): """Test get_devices with code > 0 returned.""" device_list = (orjson.dumps({'code': 1, 'msg': 'gibberish'}), 200) self.mock_api.return_value = device_list self.run_in_loop(self.vesync_obj.get_devices) assert 'Error retrieving device list' in caplog.text def test_get_devices_resp_changes(self, caplog, api_mock): """Test if structure of device list response has changed.""" resp_dict, status = ( { 'code': 0, 'NOTresult': { 'NOTlist': [ { 'deviceType': 'wifi-switch-1.3', 'type': 'wifi-switch', 'cid': 'cid1', } ] }, }, 200, ) self.mock_api.return_value = orjson.dumps(resp_dict), status self.run_in_loop(self.vesync_obj.get_devices) assert len(caplog.records) == 1 assert 'Device list in response not found' in caplog.text def test_7a_bad_conf(self, caplog, api_mock): """Test bad device list response.""" self.mock_api.return_value = (orjson.dumps(BAD_DEV_LIST), 200) self.run_in_loop(self.vesync_obj.get_devices) assert len(caplog.records) == 2 def test_7a_no_dev_list(self, caplog, api_mock): """Test if empty device list is handled correctly.""" empty_list = [] self.vesync_obj.process_devices(empty_list) assert len(caplog.records) == 1 def test_get_devices_devicetype_error(self, caplog, api_mock): """Test result and list keys exist but deviceType not in list.""" resp_dict, status = ( {'code': 0, 'result': {'list': [{'type': 'wifi-switch', 'cid': 'cid1'}]}}, 200, ) self.mock_api.return_value = orjson.dumps(resp_dict), status self.run_in_loop(self.vesync_obj.get_devices) assert len(caplog.records) == 2 assert 'Error adding device' in caplog.text def test_unknown_device(self, caplog, api_mock): """Test unknown device type is handled correctly.""" unknown_dev = json_vals.DeviceList.LIST_CONF_7A unknown_dev['devType'] = 'UNKNOWN-DEVTYPE' pyvesync.vesync.object_factory('unknown_device', unknown_dev, self.vesync_obj) assert len(caplog.records) == 1 assert 'Unknown' in caplog.text def NO_test_time_check(self, api_mock): """Test device details update throttle.""" # The throttle feature has been removed time_check = self.vesync_obj.device_time_check() assert time_check is True self.vesync_obj.last_update_ts = time.time() time_check = self.vesync_obj.device_time_check() assert time_check is False self.vesync_obj.last_update_ts = ( time.time() - self.vesync_obj.update_interval - 1 ) time_check = self.vesync_obj.device_time_check() assert time_check is True @patch('pyvesync.vesyncoutlet.VeSyncOutlet7A', autospec=True) def test_remove_device(self, outlet_patch, caplog, api_mock): """Test remove device test.""" device = copy.deepcopy(json_vals.DeviceList.LIST_CONF_7A) outlet_test = outlet_patch.return_value outlet_test.cid = '7A-CID' outlet_test.device_type = 'wifi-switch-1.3' outlet_test.device_name = '7A Device' new_list = [device] self.vesync_obj.outlets = [outlet_test] self.vesync_obj._remove_stale_devices(new_list) # pylint: disable=protected-access assert self.vesync_obj.outlets == [outlet_test] device['cid'] = '7A-CID2' self.vesync_obj._remove_stale_devices([device]) # pylint: disable=protected-access assert not self.vesync_obj.outlets assert len(caplog.records) == 2 @patch('pyvesync.vesyncoutlet.VeSyncOutdoorPlug', autospec=True) def test_add_dev_test(self, outdoor_patch, caplog, api_mock): """Test that new devices will not be added to instance.""" outdoor_inst = VeSyncOutdoorPlug( json_vals.DeviceList.LIST_CONF_OUTDOOR_2, self.vesync_obj ) self.vesync_obj.outlets = [outdoor_inst] add_test = self.vesync_obj._find_new_devices([json_vals.DeviceList.LIST_CONF_OUTDOOR_1]) assert not add_test def test_display_func(self, caplog, api_mock): """Test display function outputs text.""" self.vesync_obj.outlets.append( VeSyncOutdoorPlug(json_vals.DeviceList.device_list_item('ESO15-TB', 0), self.vesync_obj) ) self.vesync_obj.outlets.append( VeSyncOutlet10A(json_vals.DeviceList.device_list_item('ESW01-EU'), self.vesync_obj) ) self.vesync_obj.outlets.append( VeSyncOutlet15A(json_vals.DeviceList.device_list_item('ESW15-USA'), self.vesync_obj) ) self.vesync_obj.outlets.append( VeSyncOutlet7A(json_vals.DeviceList.LIST_CONF_7A, self.vesync_obj) ) self.vesync_obj.switches.append( VeSyncWallSwitch(json_vals.DeviceList.LIST_CONF_WS, self.vesync_obj) ) self.vesync_obj.fans.append( VeSyncAir131(json_vals.DeviceList.LIST_CONF_AIR, self.vesync_obj) ) self.vesync_obj.bulbs.append( VeSyncBulbESL100(json_vals.DeviceList.LIST_CONF_ESL100, self.vesync_obj) ) dev_list = [ self.vesync_obj.outlets, self.vesync_obj.switches, self.vesync_obj.fans, self.vesync_obj.bulbs, ] for device in chain(*dev_list): device.display() assert len(caplog.records) == 0 @patch('pyvesync.vesyncoutlet.VeSyncOutlet7A', autospec=True) @patch('pyvesync.vesyncoutlet.VeSyncOutlet15A', autospec=True) @patch('pyvesync.vesyncoutlet.VeSyncOutlet10A', autospec=True) @patch('pyvesync.vesyncoutlet.VeSyncOutdoorPlug', autospec=True) @patch('pyvesync.vesyncbulb.VeSyncBulbESL100', autospec=True) @patch('pyvesync.vesyncswitch.VeSyncWallSwitch', autospec=True) @patch('pyvesync.vesyncfan.VeSyncAir131', autospec=True) def test_resolve_updates( self, air_patch, ws_patch, esl100_patch, outdoor_patch, out10a_patch, out15a_patch, out7a_patch, caplog, api_mock, ): """Test process_devices() with all devices. Creates vesync object with all devices and returns device list with new set of all devices. """ out10a_patch.cid = '10A-CID1' out10a_patch.device_type = 'ESW10-EU' out10a_patch.device_name = '10A Removed' out15a_patch.cid = '15A-CID1' out15a_patch.device_type = 'ESW15-USA' out15a_patch.device_name = '15A Removed' out7a_patch.cid = '7A-CID1' out7a_patch.device_type = 'wifi-switch-1.3' out7a_patch.device_name = '7A Removed' outdoor_patch.cid = 'OUTDOOR-CID1' outdoor_patch.device_type = 'ESO15-TB' outdoor_patch.device_name = 'Outdoor Removed' esl100_patch.cid = 'BULB-CID1' esl100_patch.device_type = 'ESL100' esl100_patch.device_name = 'Bulb Removed' ws_patch.cid = 'WS-CID2' ws_patch.device_name = 'Switch Removed' ws_patch.device_type = 'ESWL01' air_patch.cid = 'AirCID2' air_patch.device_type = 'LV-PUR131S' air_patch.device_name = 'fan Removed' json_ret = json_vals.DeviceList.FULL_DEV_LIST self.vesync_obj.outlets.extend( [out7a_patch, out10a_patch, outdoor_patch] ) self.vesync_obj.switches.extend([ws_patch]) self.vesync_obj.fans.extend([air_patch]) self.vesync_obj.bulbs.extend([esl100_patch]) self.vesync_obj.process_devices(json_ret) assert len(self.vesync_obj.outlets) == 6 assert len(self.vesync_obj.switches) == 2 assert len(self.vesync_obj.fans) == 4 assert len(self.vesync_obj.bulbs) == 4 webdjoe-pyvesync-eb8cecb/src/tests/xtest_x_vesync_outdoor.py000066400000000000000000000172341507433633000250220ustar00rootroot00000000000000"""Test scripts for Etekcity Outdoor Outlet.""" from typing import Any, Dict, Union from copy import deepcopy import orjson from pyvesync.devices.vesyncoutlet import VeSyncOutdoorPlug from pyvesync.utils.helpers import Helpers as Helpers import call_json import call_json_outlets from base_test_cases import TestBase from defaults import TestDefaults DEVICE_TYPE = 'ESO15-TB' DEV_LIST_DETAIL: Dict[str, Union[str, int, float] ] = call_json.DeviceList.device_list_item(DEVICE_TYPE, 0) DEV_LIST_DETAIL_2: Dict[str, Any] = call_json.DeviceList.device_list_item(DEVICE_TYPE, 1) CORRECT_OUTDOOR_LIST: Dict[str, Any] = deepcopy(call_json.DeviceList.list_response_base) CORRECT_OUTDOOR_LIST['result']['list'].extend([DEV_LIST_DETAIL, DEV_LIST_DETAIL_2]) CORRECT_OUTDOOR_RESP: tuple = (CORRECT_OUTDOOR_LIST, 200) ENERGY_HISTORY: tuple = call_json_outlets.ENERGY_HISTORY CORRECT_OUTDOOR_DETAILS = call_json_outlets.DETAILS_RESPONSES[DEVICE_TYPE] BAD_OUTDOOR_LIST: tuple = call_json.DETAILS_BADCODE DEFAULTS = TestDefaults class TestVesyncOutdoorPlug(TestBase): """Test class for outdoor outlet.""" def test_outdoor_conf(self): """Tests outdoor outlet is instantiated properly.""" resp_dict, status = CORRECT_OUTDOOR_RESP self.mock_api.return_value = orjson.dumps(resp_dict), status self.run_in_loop(self.manager.get_devices) outlets = self.manager.outlets assert len(outlets) == 2 outdoor_outlet = outlets[0] assert isinstance(outdoor_outlet, VeSyncOutdoorPlug) assert outdoor_outlet.device_type == DEVICE_TYPE assert outdoor_outlet.uuid == DEFAULTS.uuid(DEVICE_TYPE) def test_outdoor_details(self): """Tests retrieving outdoor outlet details.""" resp_dict, status = CORRECT_OUTDOOR_DETAILS self.mock_api.return_value = orjson.dumps(resp_dict), status outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.manager) self.run_in_loop(outdoor_outlet.get_details) dev_details = outdoor_outlet.details assert outdoor_outlet.device_status == 'on' assert isinstance(outdoor_outlet, VeSyncOutdoorPlug) assert dev_details['active_time'] == 1 def test_outdoor_details_fail(self, caplog): """Test outdoor outlet get_details response.""" resp_dict, status = BAD_OUTDOOR_LIST self.mock_api.return_value = orjson.dumps(resp_dict), status outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.manager) self.run_in_loop(outdoor_outlet.get_details) assert len(caplog.records) == 1 assert 'details' in caplog.text def test_outdoor_outlet_onoff(self): """Test Outdoor Outlet Device On/Off Methods.""" self.mock_api.return_value = (orjson.dumps({'code': 0}), 200) outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.manager) head = Helpers.req_legacy_headers(self.manager) body = Helpers.req_body(self.manager, 'devicestatus') body['status'] = 'on' body['uuid'] = outdoor_outlet.uuid body['switchNo'] = outdoor_outlet.sub_device_no on = self.run_in_loop(outdoor_outlet.turn_on) self.mock_api.assert_called_with( '/outdoorsocket15a/v1/device/devicestatus', 'put', headers=head, json_object=body ) assert on off = self.run_in_loop(outdoor_outlet.turn_off) body['status'] = 'off' self.mock_api.assert_called_with( '/outdoorsocket15a/v1/device/devicestatus', 'put', headers=head, json_object=body ) assert off def test_outdoor_outlet_onoff_fail(self): """Test outdoor outlet On/Off Fail with Code>0.""" self.mock_api.return_value = (orjson.dumps({'code': 1}), 400) outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.manager) assert not self.run_in_loop(outdoor_outlet.turn_on) assert not self.run_in_loop(outdoor_outlet.turn_off) def test_outdoor_outlet_weekly(self): """Test outdoor outlet get_weekly_energy.""" resp_dict, status = ENERGY_HISTORY self.mock_api.return_value = orjson.dumps(resp_dict), status outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.manager) self.run_in_loop(outdoor_outlet.get_weekly_energy) body = Helpers.req_body(self.manager, 'energy_week') body['uuid'] = outdoor_outlet.uuid self.mock_api.assert_called_with( '/outdoorsocket15a/v1/device/energyweek', 'post', headers=Helpers.req_legacy_headers(self.manager), json_object=body, ) energy_dict = outdoor_outlet.energy['week'] assert energy_dict['energy_consumption_of_today'] == 1 assert energy_dict['cost_per_kwh'] == 1 assert energy_dict['max_energy'] == 1 assert energy_dict['total_energy'] == 1 assert energy_dict['data'] == [1, 1] assert outdoor_outlet.weekly_energy_total == 1 def test_outdoor_outlet_monthly(self): """Test outdoor outlet get_monthly_energy.""" resp_dict, status = ENERGY_HISTORY self.mock_api.return_value = orjson.dumps(resp_dict), status outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.manager) self.run_in_loop(outdoor_outlet.get_monthly_energy) body = Helpers.req_body(self.manager, 'energy_month') body['uuid'] = outdoor_outlet.uuid self.mock_api.assert_called_with( '/outdoorsocket15a/v1/device/energymonth', 'post', headers=Helpers.req_legacy_headers(self.manager), json_object=body, ) energy_dict = outdoor_outlet.energy['month'] assert energy_dict['energy_consumption_of_today'] == 1 assert energy_dict['cost_per_kwh'] == 1 assert energy_dict['max_energy'] == 1 assert energy_dict['total_energy'] == 1 assert energy_dict['data'] == [1, 1] assert outdoor_outlet.monthly_energy_total == 1 def test_outdoor_outlet_yearly(self): """Test outdoor outlet get_yearly_energy.""" resp_dict, status = ENERGY_HISTORY self.mock_api.return_value = orjson.dumps(resp_dict), status outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.manager) self.run_in_loop(outdoor_outlet.get_yearly_energy) body = Helpers.req_body(self.manager, 'energy_year') body['uuid'] = outdoor_outlet.uuid self.mock_api.assert_called_with( '/outdoorsocket15a/v1/device/energyyear', 'post', headers=Helpers.req_legacy_headers(self.manager), json_object=body, ) energy_dict = outdoor_outlet.energy['year'] assert energy_dict['energy_consumption_of_today'] == 1 assert energy_dict['cost_per_kwh'] == 1 assert energy_dict['max_energy'] == 1 assert energy_dict['total_energy'] == 1 assert energy_dict['data'] == [1, 1] assert outdoor_outlet.yearly_energy_total == 1 def test_history_fail(self): """Test outdoor outlet energy failure.""" bad_history = orjson.dumps({'code': 1}) self.mock_api.return_value = (bad_history, 200) outdoor_outlet = VeSyncOutdoorPlug(DEV_LIST_DETAIL, self.manager) self.run_in_loop(outdoor_outlet.update_energy) assert len(self.caplog.records) == 1 assert 'weekly' in self.caplog.text self.caplog.clear() self.run_in_loop(outdoor_outlet.get_monthly_energy) assert len(self.caplog.records) == 1 assert 'monthly' in self.caplog.text self.caplog.clear() self.run_in_loop(outdoor_outlet.get_yearly_energy) assert len(self.caplog.records) == 1 assert 'yearly' in self.caplog.text webdjoe-pyvesync-eb8cecb/src/tests/xtest_x_wall_switch.py000066400000000000000000000075721507433633000242640ustar00rootroot00000000000000import orjson import pytest from unittest.mock import patch import logging from pyvesync import VeSync from pyvesync.devices.vesyncswitch import VeSyncWallSwitch from pyvesync.utils.helpers import Helpers as Helpers import call_json import call_json_switches from base_test_cases import TestBase DEVICE_TYPE = 'ESWL01' DEV_LIST_DETAIL = call_json.DeviceList.device_list_item(DEVICE_TYPE) CORRECT_WS_LIST = call_json.DeviceList.device_list_response(DEVICE_TYPE) CORRECT_WS_DETAILS = call_json_switches.DETAILS_RESPONSES[DEVICE_TYPE] BAD_LIST = call_json.DETAILS_BADCODE DEFAULTS = call_json.TestDefaults class TestVesyncWallSwitch(TestBase): # @pytest.fixture() # def api_mock(self, caplog): # self.mock_api_call = patch('pyvesync.vesync.VeSync.async_call_api') # self.mock_api = self.mock_api_call.start() # self.mock_api.create_autospect() # self.mock_api.return_value.ok = True # self.vesync_obj = VeSync('sam@mail.com', 'pass', debug=True) # self.vesync_obj.enabled = True # self.vesync_obj.login = True # self.vesync_obj.token = DEFAULTS.token # self.vesync_obj.account_id = DEFAULTS.account_id # caplog.set_level(logging.DEBUG) # yield # self.mock_api_call.stop() def test_ws_conf(self): """Tests that Wall Switch is instantiated properly""" self.mock_api.return_value = CORRECT_WS_LIST self.run_in_loop(self.manager.get_devices) switch = self.manager.switches assert len(switch) == 1 wswitch = switch[0] assert isinstance(wswitch, VeSyncWallSwitch) assert wswitch.device_name == DEFAULTS.name(DEVICE_TYPE) assert wswitch.device_type == DEVICE_TYPE assert wswitch.cid == DEFAULTS.cid(DEVICE_TYPE) assert wswitch.uuid == DEFAULTS.uuid(DEVICE_TYPE) def test_ws_details(self): """Test WS get_details() """ resp_dict, status = CORRECT_WS_DETAILS self.mock_api.return_value = orjson.dumps(resp_dict), status wswitch = VeSyncWallSwitch(DEV_LIST_DETAIL, self.manager) self.run_in_loop(wswitch.get_details) dev_details = wswitch.details assert wswitch.device_status == 'on' assert isinstance(dev_details, dict) assert dev_details['active_time'] == 1 assert wswitch.connection_status == 'online' def test_ws_details_fail(self, caplog): """Test WS get_details with Code>0""" resp_dict, status = BAD_LIST self.mock_api.return_value = orjson.dumps(resp_dict), status vswitch15a = VeSyncWallSwitch(DEV_LIST_DETAIL, self.manager) self.run_in_loop(vswitch15a.get_details) assert len(caplog.records) == 1 assert 'details' in caplog.text def test_ws_onoff(self, caplog): """Test 15A Device On/Off Methods""" self.mock_api.return_value = (orjson.dumps({'code': 0}), 200) wswitch = VeSyncWallSwitch(DEV_LIST_DETAIL, self.manager) head = Helpers.req_legacy_headers(self.manager) body = Helpers.req_body(self.manager, 'devicestatus') body['status'] = 'on' body['uuid'] = wswitch.uuid on = self.run_in_loop(wswitch.turn_on) self.mock_api.assert_called_with( '/inwallswitch/v1/device/devicestatus', 'put', headers=head, json_object=body ) assert on off = self.run_in_loop(wswitch.turn_off) body['status'] = 'off' self.mock_api.assert_called_with( '/inwallswitch/v1/device/devicestatus', 'put', headers=head, json_object=body ) assert off def test_ws_onoff_fail(self): """Test ws On/Off Fail with Code>0""" self.mock_api.return_value = (orjson.dumps({'code': 1}), 400) vswitch15a = VeSyncWallSwitch(DEV_LIST_DETAIL, self.manager) assert not self.run_in_loop(vswitch15a.turn_on) assert not self.run_in_loop(vswitch15a.turn_off) webdjoe-pyvesync-eb8cecb/testing_scripts/000077500000000000000000000000001507433633000210745ustar00rootroot00000000000000webdjoe-pyvesync-eb8cecb/testing_scripts/README.md000066400000000000000000000064211507433633000223560ustar00rootroot00000000000000# pyvesync testing script This script can be used to test the function of the `pyvesync` library and log output to a file for better support with issues. It supports testing the library's functionality with various devices and configurations. ## Setup and Installation ### From Repository 1. Clone the repo branch `dev`: ```bash git clone -b dev --single-branch https://github.com/webdjoe/pyvesync.git ``` 2. Navigate to the repository directory: `cd pyvesync` Optionally create a virtual environment: ```bash python -m venv venv source venv/bin/activate # On Powershell use `venv\Scripts\activate.ps1` ``` 3. Install the current branch: ```bash pip install -e . ``` 4. Run the testing script: ```bash python testing_scripts/vs_console_script.py --email --password [optional arguments] ``` ### Just the script 1. Create a directory and navigate to it: ```bash mkdir pyvesync_testing cd pyvesync_testing ``` 2. Create a virtual environment: ```bash python -m venv venv source venv/bin/activate # On Powershell use `venv\Scripts\activate.ps1` ``` 3. Install the `pyvesync` library branch `dev`: ```bash pip install git+https://github.com/webdjoe/pyvesync.git@dev ``` 4. Download the `vs_console_script.py` file from the `testing_scripts` directory of the repository and place it in your current directory using a browser or `wget`/`curl` command: ```bash wget https://raw.githubusercontent.com/webdjoe/pyvesync/dev/testing_scripts/vs_console_script.py ``` 5. Run the testing script: ```bash python vs_console_script.py --email --password [optional arguments] ``` ### Running in VS Code or other IDE's The script can be run directly in an IDE like Visual Studio Code. Open the `vs_console_script.py` file and edit the `USERNAME` and `PASSWORD` variables at the top of the file with your VeSync account credentials, along with any other . Then run the script using the IDE's debug command. ## Configuration **WARNING**: The script will try to return the device to original state after testing, but it is not guaranteed to restore all states. You can configure the script by modifying the following variables in the `vs_console_script.py` file: - `USERNAME`: Your VeSync account email. - `PASSWORD`: Your VeSync account password. - `TEST_DEVICES`: Set to `True` to test device functionality. - `TEST_TIMERS`: Set to `True` to test timer functionality. - `OUTPUT_FILE`: Path to the output file for logging. - `TEST_DEV_TYPE`: Specific device type to test (Options are "bulbs", "switchs", "outlets", "humidifiers", "air_purifiers", "fans"). CONFIGURING VIA COMMAND LINE: You can also configure the script via command line arguments: ```bash python vs_console_script.py \ # or testing_scripts/vs_console_script.py if using repository method --email \ --password \ --test-devices \ # Include device methods in the test --test-timers \ # Include timer methods in the test --output-file \ --test-dev-type # Options: "bulbs", "switchs", "outlets", "humidifiers", "air_purifiers", "fans" ``` ## Logging The script logs output to both the console and a file specified by the `OUTPUT_FILE` variable. webdjoe-pyvesync-eb8cecb/testing_scripts/device_configurations.py000066400000000000000000000054671507433633000260330ustar00rootroot00000000000000"""Get configuration details from the API.""" from __future__ import annotations from pathlib import Path from typing import Any import aiofiles import orjson from pyvesync import VeSync from pyvesync.models.vesync_models import RequestDeviceConfiguration USERNAME = '' PASSWORD = '' def parse_config(data: dict) -> dict[str, list[dict]]: """Parse the configuration data from the API into a nested dictionary.""" result = {} # Navigate to productLineList data = orjson.loads(orjson.dumps(data)) # Ensure data is a dict config_list = data.get('result', {}).get('configList', []) for config in config_list: for item in config.get('items', []): item_value = item.get('itemValue') if isinstance(item_value, str): item_value = orjson.loads(item_value) product_lines = item_value.get('productLineList', []) for product_line in product_lines: for type_info in product_line.get('typeInfoList', []): type_name = type_info.get('typeName') if not type_name: continue models = [] for model_info in type_info.get('modelInfoList', []): model = model_info.get('model') if not model: continue # Add the full model_info dict under the model key models.append({model: [model_info]}) if models: result[type_name] = models return result async def fetch_config(manager: VeSync) -> dict[Any, Any] | None: """Get full device configuration from the API.""" endpoint = '/cloud/v1/app/getAppConfigurationV2' body = RequestDeviceConfiguration( accountID=manager.account_id, token=manager.token, ) response, _ = await manager.async_call_api( api=endpoint, method='post', json_object=body, ) return response async def main() -> None: """Main function to fetch and display device configurations.""" async with VeSync(USERNAME, PASSWORD, 'US', debug=True) as manager: await manager.login() manager.verbose = True config = await fetch_config(manager) if not config: print('Failed to fetch configuration.') return parsed_config = parse_config(config) print(orjson.dumps(parsed_config, option=orjson.OPT_INDENT_2).decode('utf-8')) output_path = Path('models.json') async with aiofiles.open(output_path, 'w', encoding='utf-8') as file: await file.write( orjson.dumps(parsed_config, option=orjson.OPT_INDENT_2).decode('utf-8') ) if __name__ == '__main__': import asyncio asyncio.run(main()) webdjoe-pyvesync-eb8cecb/testing_scripts/vs_test_script.py000066400000000000000000000541711507433633000245310ustar00rootroot00000000000000"""Testing script for pyvesync library. This script tests the functionality of the pyvesync library by interacting with VeSync devices. It can be run from the command line or in VS code. It supports testing devices, timers, and logging to a file. Usage: python testing_scripts/vs_console_script.py \ --email \ --password \ [--test_devices] \ [--test_timers] \ [--output-file ] \ [--test_dev_type ] By default, it will only log in, pull the device list, and update all devices. Using the `--test_devices` flag will run device specific methods on all devices. The `--test_timers` flag will run timer methods on all devices. To test a specific device type, use the `--test_dev_type` flag with one of the following values: "bulbs", "switches", "outlets", "humidifiers", "air_purifiers", "fans". If no device type is specified, it will test all devices. Example: The script can be debugged in an IDE by setting the USERNAME, PASSWORD and other arguments at the top of the file. This injects the values into the command line arguments. Command line arguments will override these values. """ from __future__ import annotations import argparse import asyncio import logging import random import sys from pathlib import Path from typing import TYPE_CHECKING, Literal from pyvesync import VeSync from pyvesync.const import PurifierModes if TYPE_CHECKING: from pyvesync.base_devices import VeSyncBaseToggleDevice from pyvesync.device_container import DeviceContainer _T_DEVS = Literal['bulbs', 'switchs', 'outlets', 'humidifiers', 'air_purifiers', 'fans'] # Manually configure script arguments USERNAME: str | None = None PASSWORD: str | None = None REGION: str | None = None TEST_DEVICES: bool | None = None TEST_TIMERS: bool | None = None OUTPUT_FILE: str | None = None TEST_DEV_TYPE: _T_DEVS | None = None # Set to a specific device type to test, e.g., "vesyncbulb", "vesyncswitch", etc. logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) logger.info('Starting VeSync device test...') stream_handler = logging.StreamHandler(sys.stdout) stream_handler.setLevel(logging.INFO) formatter = logging.Formatter( fmt='%(asctime)s - %(levelname)s - %(name)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S', ) stream_handler.setFormatter(formatter) logger.addHandler(stream_handler) logger.propagate = False _SEP = '-' * 18 # Logging messages _LOG_DEVICE_START = _SEP + ' Testing %s %s ' + _SEP async def _random_await() -> None: await asyncio.sleep(random.randrange(10, 30) / 10) # noqa: S311 logger.info('Random await finished') async def vesync_test( # noqa: PLR0913 username: str, password: str, country_code: str, output_file: str, test_devices: bool = False, test_timers: bool = False, test_dev_type: _T_DEVS | None = None, ) -> None: """Main function to test VeSync devices. Note: This will attempt to return the device to the original state, but it is not guaranteed. Timers may be cleared as well. Args: username (str): VeSync account email. password (str): VeSync account password. country_code (str): VeSync account region. output_file (str): Path to the output file for logging. test_devices (bool): If True, run tests on devices. test_timers (bool): If True, run tests on timers. test_dev_type (_T_DEVS | None): Specific device type to test. """ output_path = Path(output_file).resolve() if not output_path.parent.exists(): logger.error('Output directory %s does not exist.', output_path.parent) return file_handler = logging.FileHandler(output_path) logger.addHandler(file_handler) # Instantiate VeSync object vs = await VeSync(username, password, country_code or 'US').__aenter__() vs.debug = True # Enable debug mode vs.verbose = True # Enable verbose mode vs.redact = True # Redact sensitive information in logs vs.log_to_file(str(output_path), std_out=False) # Log to specified file logger.info('VeSync instance created, logging to %s', output_path) # Login to VeSync account and check for success logger.info('Logging in to VeSync account...') await vs.login() logger.info('Login successful, pulling device list...') await _random_await() # Pull device list and update all devices await vs.update() await _random_await() logger.info('Device list pulled successfully.') # Run tests for outlets if vs.devices.outlets and (test_devices is True or test_dev_type == 'outlets'): await outlets(vs, update=False) # Run tests for air purifiers if vs.devices.air_purifiers and ( test_devices is True or test_dev_type == 'air_purifiers' ): await air_purifiers(vs, update=False) # Run tests for humidifiers if vs.devices.humidifiers and ( test_devices is True or test_dev_type == 'humidifiers' ): await humidifiers(vs, update=False) # Run tests for bulbs if vs.devices.bulbs and (test_devices is True or test_dev_type == 'bulbs'): await bulbs(vs, update=False) # Run tests for switches if vs.devices.switches and (test_devices is True or test_dev_type == 'switches'): await switches(vs, update=False) # Test timers if requested if test_timers is True: await device_timers(vs.devices, update=False) await vs.__aexit__(None, None, None) # Clean up VeSync instance logger.info('Finished testing VeSync devices, results logged to %s', output_path) async def common_tests( device: VeSyncBaseToggleDevice, update: bool = False ) -> bool | None: """Run common tests for a VeSync device.""" logger.info(_LOG_DEVICE_START, device.device_type, device.device_name) logger.info(device) if update is True: await device.update() await _random_await() if device.state.connection_status == 'offline': logger.info('%s is offline, skipping', device.device_name) return None logger.info('Current device state: %s', device.state.to_json(indent=True)) if device.state.device_status == 'off': logger.info('Initial state is off, turning on device') await device.turn_on() logger.info(device.state.device_status) await _random_await() return False logger.info('Initial state is on, turning off device') await device.turn_off() logger.info(device.state.device_status) await _random_await() logger.info('Turning on device') await device.turn_on() logger.info(device.state.device_status) await _random_await() return True async def device_timers(dev_list: DeviceContainer, update: bool = False) -> None: """Test timers for all devices in the device list.""" # Get dev types from device list to not repeat tests dev_types = [dev.device_type for dev in dev_list] dev_set = set(dev_types) for dev in dev_list: logger.info( '%s Testing timer for %s %s %s', _SEP, dev.device_type, dev.device_name, _SEP ) if dev.device_type not in dev_set: continue dev_set.remove(dev.device_type) if update is True: await dev.update() await _random_await() await dev.get_timer() logger.info('Current timer state: %s', dev.state.timer) await _random_await() if dev.state.timer is None: logger.info('Setting timer for 1 hour to turn on device') await dev.set_timer(3600, 'on') logger.info('Current timer state: %s', dev.state.timer) await _random_await() logger.info('Updating device state after setting timer') await dev.update() logger.info('Current timer state: %s', dev.state.timer) await _random_await() logger.info('Getting current active timer via API') await dev.get_timer() logger.info('Current timer state: %s', dev.state.timer) await _random_await() logger.info('Clearing timer') await dev.clear_timer() logger.info('Current timer state: %s', dev.state.timer) await _random_await() logger.info( '%s Finished testing timer for %s %s %s', _SEP, dev.device_type, dev.device_name, _SEP, ) async def humidifiers(manager: VeSync, update: bool = False) -> None: """Test humidifiers in the VeSync device manager.""" logger.info('%s Testing humidifiers %s', _SEP, _SEP) dev_types = set() for dev in manager.devices.humidifiers: # Ensure the same device type is not tested multiple times if dev.device_type in dev_types: continue dev_types.add(dev.device_type) initial_on = await common_tests(dev, update) if initial_on is None: continue logger.info(dev.state.to_json(indent=True)) initial_state = dev.state.to_dict() logger.debug('Initial state: %s', dev.state.to_json(indent=True)) logger.info('Setting state to auto mode') await dev.set_auto_mode() logger.debug(dev.state.mode) await _random_await() logger.info('Setting state to manual mode') await dev.set_manual_mode() logger.debug(dev.state.mode) await _random_await() logger.info('Setting mist level to 2') await dev.set_mist_level(2) logger.debug('mist_level: %s', dev.state.mist_level) logger.debug('mist_virtual_level: %s', dev.state.mist_virtual_level) await _random_await() logger.info('Setting sleep mode') await dev.set_sleep_mode() logger.debug(dev.state.mode) await _random_await() logger.info('Setting target humidity to 50%') await dev.set_humidity(50) logger.debug(dev.state.target_humidity) await _random_await() if initial_on is True: logger.info('Returning to initial state') await dev.set_mist_level(initial_state['mist_level']) await dev.set_mode(initial_state['mode']) await dev.set_humidity(initial_state['target_humidity']) else: await dev.turn_off() await _random_await() async def bulbs(manager: VeSync, update: bool = False) -> None: # noqa: C901 """Test bulbs in the VeSync device manager.""" logger.info('%s Testing bulbs %s', _SEP, _SEP) dev_types = set() for dev in manager.devices.bulbs: if dev.device_type in dev_types: continue dev_types.add(dev.device_type) initial_on = await common_tests(dev, update) if initial_on is None: continue initial_state = dev.state.to_dict() logger.info(dev.state.to_json(indent=True)) # Print the current state of the device if dev.supports_brightness: logger.info('Setting brightness to 30%') await dev.set_brightness(30) logger.debug('Brightness: %s', dev.state.brightness) await _random_await() if dev.supports_color_temp: logger.info('Setting color temperature to 10') await dev.set_color_temp(10) logger.debug('Color Temperature: %s', dev.state.color_temp) await _random_await() if dev.supports_multicolor: logger.info('Setting RGB color to (255, 40, 30)') await dev.set_rgb(255, 40, 30) logger.debug('Color: %s', dev.state.rgb) await _random_await() logger.info('Returning to initial state') if initial_on is False: await dev.turn_off() await _random_await() return if initial_state['color_temp'] is not None: await dev.set_color_temp(initial_state['color_temp']) if initial_state['brightness'] is not None: await dev.set_brightness(initial_state['brightness']) if initial_state['rgb'] is not None: await dev.set_rgb( initial_state['rgb'][0], initial_state['rgb'][1], initial_state['rgb'][2], ) await _random_await() async def switches(manager: VeSync, update: bool = False) -> None: # noqa: C901, PLR0915 """Test switches in the VeSync device manager.""" logger.info('%s Testing switches %s', _SEP, _SEP) dev_types = set() for dev in manager.devices.switches: if dev.device_type in dev_types: continue dev_types.add(dev.device_type) initial_on = await common_tests(dev, update) if initial_on is None: continue logger.debug(dev.state.to_json(indent=True)) initial_state = dev.state.to_dict() if dev.supports_dimmable: logger.debug('Setting brightness to 100%') await dev.set_brightness(100) logger.debug(dev.state.brightness) await _random_await() if dev.supports_indicator_light: if initial_state['indicator_status'] == 'on': logger.debug('Initial state is on, turning off indicator light') await dev.turn_off_indicator_light() logger.debug('indicator_status: %s', dev.state.indicator_status) await _random_await() await dev.turn_on_indicator_light() logger.debug('indicator_status: %s', dev.state.indicator_status) await _random_await() else: logger.debug('Initial state is off, turning on indicator light') await dev.turn_on_indicator_light() logger.debug('indicator_status: %s', dev.state.indicator_status) await _random_await() await dev.turn_off_indicator_light() logger.debug('indicator_status: %s', dev.state.indicator_status) await _random_await() if dev.supports_backlight_color: if initial_state['backlight_status'] == 'on': logger.debug('Initial state is on, turning off RGB backlight') await dev.turn_off_rgb_backlight() logger.debug('backlight_status: %s', dev.state.backlight_status) await _random_await() logger.debug('Turrning on RGB backlight') await dev.turn_on_rgb_backlight() logger.debug('backlight_status: %s', dev.state.backlight_status) await _random_await() logger.debug('Setting backlight color to (50, 30, 15)') await dev.set_backlight_color(50, 30, 15) logger.debug('backlight_color: %s', dev.state.backlight_color) await _random_await() if initial_state['backlight_status'] == 'off': await dev.turn_off_rgb_backlight() await _random_await() else: await dev.set_backlight_color( initial_state['backlight_color'][0], initial_state['backlight_color'][1], initial_state['backlight_color'][2], ) await _random_await() if initial_state['brightness'] is not None: await dev.set_brightness(initial_state['brightness']) await _random_await() async def air_purifiers(manager: VeSync, update: bool = False) -> None: # noqa: C901, PLR0915 """Test air purifiers in the VeSync device manager.""" logger.info('%s Testing air purifiers %s', _SEP, _SEP) dev_types = set() for dev in manager.devices.air_purifiers: if dev.device_type in dev_types: continue dev_types.add(dev.device_type) initial_on = await common_tests(dev, update) if initial_on is None: continue logger.info(dev.state.to_json(indent=True)) initial_state = dev.state.to_dict() if initial_state['display_set_state'] is True: logger.info('Turning off display') await dev.turn_off_display() logger.info('display_set_state: %s', dev.state.display_set_status) logger.info('display_status: %s', dev.state.display_status) await _random_await() logger.info('Turning on display') await dev.turn_on_display() logger.info('display_set_state: %s', dev.state.display_set_status) logger.info('display_status: %s', dev.state.display_status) await _random_await() else: logger.info('Display is off, turning on display') await dev.turn_on_display() logger.info('display_set_state: %s', dev.state.display_set_status) logger.info('display_status: %s', dev.state.display_status) await _random_await() logger.info('Turning off display') await dev.turn_off_display() logger.info('display_set_state: %s', dev.state.display_set_status) logger.info('display_status: %s', dev.state.display_status) await _random_await() if PurifierModes.AUTO in dev.modes: logger.info('Setting auto mode') await dev.set_auto_mode() logger.info('mode: %s', dev.state.mode) await _random_await() if PurifierModes.SLEEP in dev.modes: logger.info('Setting sleep mode') await dev.set_sleep_mode() logger.info('mode: %s', dev.state.mode) await _random_await() if PurifierModes.MANUAL in dev.modes: logger.info('Setting manual mode') await dev.set_manual_mode() logger.info('mode: %s', dev.state.mode) await _random_await() logger.info('Setting fan speed to 1') await dev.set_fan_speed(1) logger.info('fan_level: %s', dev.state.fan_level) await dev.set_fan_speed(initial_state['fan_level']) if initial_state['child_lock'] is True: logger.info('Turning off child lock') await dev.turn_off_child_lock() logger.info('child_lock: %s', dev.state.child_lock) await _random_await() await dev.turn_on_child_lock() await _random_await() else: logger.info('Turning on child lock') await dev.turn_on_child_lock() logger.info('child_lock: %s', dev.state.child_lock) await _random_await() await dev.turn_off_child_lock() await _random_await() if initial_on is False: logger.info('Turning off device') await dev.turn_off() await _random_await() return if initial_state['mode'] is not PurifierModes.MANUAL: await dev.set_mode(initial_state['mode']) await _random_await() async def outlets(manager: VeSync, update: bool = False) -> None: """Test outlets in the VeSync device manager.""" logger.info('%s Testing outlets %s', _SEP, _SEP) dev_types = set() for dev in manager.devices.outlets: if dev.device_type in dev_types: continue dev_types.add(dev.device_type) initial_on = await common_tests(dev, update) if initial_on is None: continue logger.info(dev.state.to_json(indent=True)) initial_state = dev.state.to_dict() if dev.supports_nightlight: if initial_state['nightlight_status'] == 'on': logger.info('Turning off nightlight') await dev.turn_off_nightlight() logger.info('nightlight_status: %s', dev.state.nightlight_status) await _random_await() await dev.turn_on_nightlight() else: logger.info('Turning on nightlight') await dev.turn_on_nightlight() logger.info('nightlight_status: %s', dev.state.nightlight_status) await _random_await() await dev.turn_off_nightlight() if dev.supports_energy: logger.info('Getting energy data') await dev.get_monthly_energy() await dev.get_weekly_energy() await dev.get_yearly_energy() logger.info('Energy usage: %s', dev.state.energy) logger.info('Power: %s', dev.state.power) logger.info('Voltage: %s', dev.state.voltage) if dev.state.monthly_history: logger.info('Monthly history: %s', dev.state.monthly_history.to_json()) if dev.state.weekly_history: logger.info('Weekly history: %s', dev.state.weekly_history.to_json()) if __name__ == '__main__': parser = argparse.ArgumentParser( description='Test pyvesync library and devices.', epilog='Run script with --help for more information.', ) parser.add_argument('--email', default=USERNAME, help='Email for VeSync account') parser.add_argument( '--password', default=PASSWORD, help='Password for VeSync account' ) parser.add_argument( '--country-code', default=REGION, help='Country of the VeSync account in ISO 3166 Alpha-2 format', ) parser.add_argument( '--test_devices', action='store_true', default=TEST_DEVICES, help='Include devices in test, default: False', ) parser.add_argument( '--test_timers', action='store_true', default=TEST_TIMERS, help='Run tests on timers, default: False', ) parser.add_argument( '--output-file', default='vesync.log', help='Relative or absolute path to output file', ) parser.add_argument( '--test_dev_type', choices=['bulbs', 'switches', 'outlets', 'humidifiers', 'air_purifiers', 'fans'], default=TEST_DEV_TYPE, help="Specific device type to test, e.g., 'bulbs', 'switches', etc.", ) args = parser.parse_args() if args.email is None or args.password is None: logger.error( 'Username and password must be provided' 'via command line arguments or script variables.' ) sys.exit(1) asyncio.run( vesync_test( args.email, args.password, args.country_code, args.output_file, args.test_devices, args.test_timers, args.test_dev_type, ) ) webdjoe-pyvesync-eb8cecb/tox.ini000066400000000000000000000012171507433633000171640ustar00rootroot00000000000000[tox] envlist = lint, flake8, mypy, ruff, 3.1{1,2,3} skip_missing_interpreters = True isolated_build = True ignore_basepython_conflict = True [testenv] deps = pytest pyyaml commands = pip install .[dev] pytest -q {posargs} [testenv:lint] ignore_errors = True deps = pylint commands = pylint {posargs} src/pyvesync [testenv:flake8] ignore_errors = True deps = flake8 flake8-docstrings commands = flake8 src/pyvesync [testenv:mypy] ignore_errors = True deps = mypy types-deprecated allowlist_externals = mypy commands = mypy src/pyvesync [testenv:ruff] ignore_errors = True deps = ruff commands = ruff check src/pyvesync