From ab6aa2ee9a0384c2e939f6178732e130fb0ba373 Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Sat, 19 Oct 2024 23:34:26 -0400 Subject: [PATCH 1/4] Update Docs Update docs in vesync, vesyncbaseobject and vesyncbulb. Add ruff.toml and fix preliminary linting errors --- .gitignore | 8 +- .pylintrc | 6 + README.md | 28 +- mypy.ini | 2 +- ruff.toml | 57 ++++ src/pyvesync/helpers.py | 487 +++++++++++++++++++++-------- src/pyvesync/vesync.py | 55 +++- src/pyvesync/vesyncbasedevice.py | 81 ++++- src/pyvesync/vesyncbulb.py | 440 ++++++++++++++++++++------ src/pyvesync/vesyncfan.py | 509 +++++++++++++++++++++---------- 10 files changed, 1279 insertions(+), 394 deletions(-) create mode 100644 ruff.toml diff --git a/.gitignore b/.gitignore index d3c97ae..58efc16 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,10 @@ test.py tools/__init__.py tools/vesyncdevice.py pyvesync.und -.venv \ No newline at end of file +.venv + + +mkdocs.yml +requirements-docs.txt +docs/ +site/ \ No newline at end of file diff --git a/.pylintrc b/.pylintrc index 3ccc1bd..8794c95 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,6 +1,12 @@ [MASTER] ignore=src/tests, tools/ + docs/ + site/ + +load-plugins= + pylint.extensions.docparams, + pylint.extensions.mccabe [BASIC] good-names=i,j,k,x,r,e,v,_,b,dt,d diff --git a/README.md b/README.md index c0e5e47..02abcff 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,12 @@ Install the latest version from pip: pip install pyvesync ``` + + ## Supported Devices + + ### Etekcity Outlets 1. Voltson Smart WiFi Outlet- Round (7A model ESW01-USA) @@ -80,11 +84,17 @@ pip install pyvesync 4. Voltson Smart WiFi Outlet - Rectangle (15A model ESW15-USA) 5. 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 @@ -103,21 +113,27 @@ pip install pyvesync ### Valceno Bulbs -1. Multicolor Bulb (XYD0001) +1. Valceno Multicolor Bulb (XYD0001) ### Levoit Humidifiers 1. Dual 200S 2. Classic 300S -4. LV600S -7. OasisMist 450S -7. OasisMist 600S -8. OasisMist 1000S +3. LV600S +4. OasisMist 450S +5. OasisMist 600S +6. OasisMist 1000S -Cosori Air Fryer +### Cosori Air Fryer 1. Cosori 3.7 and 5.8 Quart Air Fryer +### Fans + +1. 42 in. Tower Fan + + + ## Usage To start with the module: diff --git a/mypy.ini b/mypy.ini index 3c131d8..4041cff 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,5 @@ [mypy] -python_version=3.8 +python_version=3.9 [mypy-src.pyvesync.vesyncfan.fan_modules] ignore_missing_imports = True diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..8248d67 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,57 @@ +exclude = [ + ".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 = [] +unfixable = ["B"] + +[lint.per-file-ignores] +"vesync.py" = ["F403"] + +[lint.pep8-naming] +extend-ignore-names = ["displayJSON"] + +[lint.pydocstyle] +convention = "google" + +[lint.pylint] +max-public-methods = 30 + +[format] +quote-style = "preserve" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "lf" + diff --git a/src/pyvesync/helpers.py b/src/pyvesync/helpers.py index a3a224c..29d54ae 100644 --- a/src/pyvesync/helpers.py +++ b/src/pyvesync/helpers.py @@ -1,15 +1,18 @@ """Helper functions for VeSync API.""" - +from __future__ import annotations import hashlib import logging import time import json import colorsys from dataclasses import dataclass, field, InitVar -from typing import Any, Dict, NamedTuple, Optional, Union +from typing import Any, NamedTuple, Optional, Union, TYPE_CHECKING import re import requests +if TYPE_CHECKING: + from pyvesync.vesync import VeSync + logger = logging.getLogger(__name__) @@ -31,16 +34,39 @@ USER_TYPE = '1' BYPASS_APP_V = "VeSync 3.0.51" +BYPASS_HEADER_UA = 'okhttp/3.12.1' + NUMERIC = Optional[Union[int, float, str]] +REQUEST_T = dict[str, Any] + class Helpers: """VeSync Helper Functions.""" @staticmethod - def req_headers(manager) -> Dict[str, str]: - """Build header for api requests.""" - headers = { + def req_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, 'appVersion': APP_VERSION, @@ -48,29 +74,80 @@ def req_headers(manager) -> Dict[str, str]: 'tk': manager.token, 'tz': manager.time_zone, } - return headers @staticmethod - def req_header_bypass() -> Dict[str, str]: - """Build header for api requests on 'bypass' endpoint.""" + 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': 'okhttp/3.12.1', + 'User-Agent': BYPASS_HEADER_UA, } @staticmethod - def req_body_base(manager) -> Dict[str, str]: - """Return universal keys for body of api requests.""" + def req_body_base(manager) -> 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) -> Dict[str, str]: - """Keys for authenticating api requests.""" + def req_body_auth(manager) -> 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 - def req_body_details() -> Dict[str, str]: - """Detail keys for api requests.""" + def req_body_details() -> REQUEST_T: + """Detail keys for api requests. + + 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, @@ -79,27 +156,49 @@ def req_body_details() -> Dict[str, str]: } @classmethod - def req_body(cls, manager, type_) -> Dict[str, Any]: - """Builder for body of api requests.""" - body = cls.req_body_base(manager) + def req_body(cls, manager: VeSync, type_: str) -> REQUEST_T: + """Builder for body of api requests. + + 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() # type: ignore + body |= cls.req_body_details() body |= { 'email': manager.username, 'password': cls.hash_password(manager.password), 'devToken': '', 'userType': USER_TYPE, 'method': 'login' - } # type: ignore + } return body - body |= cls.req_body_auth(manager) # type: ignore + body |= cls.req_body_auth(manager) if type_ == 'devicestatus': return body - body |= cls.req_body_details() # type: ignore + body |= cls.req_body_details() if type_ == 'devicelist': body['method'] = 'devices' @@ -151,40 +250,79 @@ def hash_password(string) -> str: @classmethod def redactor(cls, stringvalue: str) -> str: - """Redact sensitive strings from debug output.""" + """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 + - 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''.join(( - '(?i)', - '((?<=token": ")|', - '(?<=password": ")|', - '(?<=email": ")|', - '(?<=tk": ")|', - '(?<=accountId": ")|', - '(?<=authKey": ")|', - '(?<=uuid": ")|', - '(?<=cid": "))', - '[^"]+') - ), - '##_REDACTED_##', stringvalue) + stringvalue = re.sub( + ( + r'(?i)' + r'((?<=token": ")|' + r'(?<=password": ")|' + r'(?<=email": ")|' + r'(?<=tk": ")|' + r'(?<=accountId": ")|' + r'(?<=authKey": ")|' + r'(?<=uuid": ")|' + r'(?<=cid": "))' + r'[^"]+' + ), + '##_REDACTED_##', + stringvalue, + ) return stringvalue @staticmethod def nested_code_check(response: dict) -> bool: - """Return true if all code values are 0.""" + """Return true if all code values are 0. + + Args: + response (dict): API response. + + Returns: + bool: True if all code values are 0, False otherwise. + """ if isinstance(response, dict): for key, value in response.items(): - if key == 'code': - if value != 0: - return False - elif isinstance(value, dict): - if not Helpers.nested_code_check(value): - return False + if (key == 'code' and value != 0) or \ + (isinstance(value, dict) and \ + not Helpers.nested_code_check(value)): + return False return True @staticmethod def call_api(api: str, method: str, json_object: Optional[dict] = None, headers: Optional[dict] = None) -> tuple: - """Make API calls by passing endpoint, header and body.""" + """Make API calls by passing endpoint, header and body. + + api argument is appended to https://smartapi.vesync.com url + + Args: + api (str): Endpoint to call with https://smartapi.vesync.com. + method (str): HTTP method to use. + json_object (dict): JSON object to send in body. + headers (dict): Headers to send with request. + + Returns: + tuple: Response and status code. + """ response = None status_code = None @@ -240,7 +378,27 @@ def code_check(r: dict) -> bool: @staticmethod def build_details_dict(r: dict) -> dict: - """Build details dictionary from API response.""" + """Build details dictionary from API response. + + Args: + r (dict): API response. + + Returns: + dict: Details dictionary. + + Examples: + >>> build_details_dict(r) + { + 'active_time': 1234, + 'energy': 168, + 'night_light_status': 'on', + 'night_light_brightness': 50, + 'night_light_automode': 'on', + 'power': 1, + 'voltage': 120, + } + + """ return { 'active_time': r.get('activeTime', 0), 'energy': r.get('energy', 0), @@ -253,7 +411,17 @@ def build_details_dict(r: dict) -> dict: @staticmethod def build_energy_dict(r: dict) -> dict: - """Build energy dictionary from API response.""" + """Build energy dictionary from API response. + + Note: + For use with **Etekcity** outlets only + + Args: + r (dict): API response. + + Returns: + dict: Energy dictionary. + """ return { 'energy_consumption_of_today': r.get( 'energyConsumptionOfToday', 0), @@ -266,7 +434,33 @@ def build_energy_dict(r: dict) -> dict: @staticmethod def build_config_dict(r: dict) -> dict: - """Build configuration dictionary from API response.""" + """Build configuration dictionary from API response. + + Contains firmware version, max power, threshold, + power protection status, and energy saving status. + + Note: + Energy and power stats only available for **Etekcity** + outlets. + + Args: + r (dict): API response. + + Returns: + dict: Configuration dictionary. + + Examples: + >>> build_config_dict(r) + { + 'current_firmware_version': '1.2.3', + 'latest_firmware_version': '1.2.4', + 'max_power': 1000, + 'threshold': 500, + 'power_protection': 'on', + 'energy_saving_status': 'on', + } + + """ if r.get('threshold') is not None: threshold = r.get('threshold') else: @@ -281,9 +475,33 @@ def build_config_dict(r: dict) -> dict: } @classmethod - def bypass_body_v2(cls, manager): - """Build body dict for bypass calls.""" - bdy = {} + def bypass_body_v2(cls, manager) -> dict: + """Build body dict for second version of bypass api calls. + + Args: + manager (VeSyncManager): Instance of VeSyncManager. + + Returns: + dict: Body dictionary for bypass api calls. + + Examples: + >>> bypass_body_v2(manager) + { + 'timeZone': manager.time_zone, + 'acceptLanguage': 'en', + 'accountID': manager.account_id, + 'token': manager.token, + 'appVersion': APP_VERSION, + 'phoneBrand': PHONE_BRAND, + 'phoneOS': PHONE_OS, + 'traceId': str(int(time.time())), + 'method': 'bypassV2', + 'debugMode': False, + 'deviceRegion': DEFAULT_REGION, + } + + """ + bdy: dict[str, Union[str, bool]] = {} bdy.update( **cls.req_body(manager, "bypass") ) @@ -293,8 +511,20 @@ def bypass_body_v2(cls, manager): return bdy @staticmethod - def bypass_header(): - """Build bypass header dict.""" + def bypass_header() -> dict: + """Build bypass header dict. + + Returns: + dict: Header dictionary for bypass api calls. + + Examples: + >>> bypass_header() + { + 'Content-Type': 'application/json; charset=UTF-8', + 'User-Agent': BYPASS_HEADER_UA, + } + + """ return { 'Content-Type': 'application/json; charset=UTF-8', 'User-Agent': 'okhttp/3.12.1', @@ -302,7 +532,18 @@ def bypass_header(): @staticmethod def named_tuple_to_str(named_tuple: NamedTuple) -> str: - """Convert named tuple to string.""" + """Convert named tuple to string. + + Args: + named_tuple (namedtuple): Named tuple to convert to string. + + Returns: + str: String representation of named tuple. + + Examples: + >>> named_tuple_to_str(HSV(100, 50, 75)) + 'hue: 100, saturation: 50, value: 75, ' + """ tuple_str = '' for key, val in named_tuple._asdict().items(): tuple_str += f'{key}: {val}, ' @@ -310,16 +551,30 @@ def named_tuple_to_str(named_tuple: NamedTuple) -> str: class HSV(NamedTuple): - """HSV color space.""" + """HSV color space named tuple. + Used as an attribute in the `pyvesync.helpers.Color` dataclass. + + 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 class RGB(NamedTuple): - """RGB color space.""" + """RGB color space named tuple. + Used as an attribute in the :obj:`pyvesync.helpers.Color` dataclass. + + 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 @@ -327,21 +582,29 @@ class RGB(NamedTuple): @dataclass class Color: - """Dataclass for color values. + """ + Dataclass for color values. For HSV, pass hue as value in degrees 0-360, saturation and value as values - between 0 and 100. - - For RGB, pass red, green and blue as values between 0 and 255. - - To instantiate pass kw arguments for colors hue, saturation and value or - red, green and blue. - - Instance attributes are: - hsv (nameduple) : hue (0-360), saturation (0-100), value (0-100) - - rgb (namedtuple) : red (0-255), green (0-255), blue - + between 0 and 100. For RGB, pass red, green and blue as values between 0 and 255. This + dataclass provides validation and conversion methods for both HSV and RGB color spaces. + + Notes: + To instantiate pass kw arguments for colors with *either* **hue, saturation and value** *or* + **red, green and blue**. RGB will take precedence if both are provided. Once instantiated, + the named tuples `hsv` and `rgb` will be available as attributes. + + Args: + red (int): Red value of RGB color, 0-255 + green (int): Green value of RGB color, 0-255 + blue (int): Blue value of RGB color, 0-255 + hue (int): Hue value of HSV color, 0-360 + saturation (int): Saturation value of HSV color, 0-100 + value (int): Value (brightness) value of HSV color, 0-100 + + Attributes: + hsv (namedtuple): hue (0-360), saturation (0-100), value (0-100) see [`HSV dataclass`][pyvesync.helpers.HSV] + rgb (namedtuple): red (0-255), green (0-255), blue (0-255) see [`RGB dataclass`][pyvesync.helpers.RGB] """ red: InitVar[NUMERIC] = field(default=None, repr=False, compare=False) @@ -366,8 +629,8 @@ def __post_init__(self, red, green, blue, hue, saturation, value): logger.error('No color values provided') @staticmethod - def min_max(value: Union[int, float, str], min_val: float, - max_val: float, default: float) -> float: + def _min_max(value: Union[int, float, str], min_val: float, + max_val: float, default: float) -> float: """Check if value is within min and max values.""" try: val = max(min_val, (min(max_val, round(float(value), 2)))) @@ -380,9 +643,9 @@ def valid_hsv(cls, h: Union[int, float, str], s: Union[int, float, str], v: Union[int, float, str]) -> tuple: """Check if HSV values are valid.""" - valid_hue = float(cls.min_max(h, 0, 360, 360)) - valid_saturation = float(cls.min_max(s, 0, 100, 100)) - valid_value = float(cls.min_max(v, 0, 100, 100)) + valid_hue = float(cls._min_max(h, 0, 360, 360)) + valid_saturation = float(cls._min_max(s, 0, 100, 100)) + valid_value = float(cls._min_max(v, 0, 100, 100)) return ( valid_hue, valid_saturation, @@ -394,7 +657,7 @@ def valid_rgb(cls, r: float, g: float, b: float) -> list: """Check if RGB values are valid.""" rgb = [] for val in (r, g, b): - valid_val = cls.min_max(val, 0, 255, 255) + valid_val = cls._min_max(val, 0, 255, 255) rgb.append(valid_val) return rgb @@ -428,45 +691,27 @@ def rgb_to_hsv(red, green, blue) -> HSV: @dataclass class Timer: - """Dataclass for timers. - - Parameters - ---------- - 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 - - Attributes - ---------- - update_time : int - Timestamp of last update - - Properties - ---------- - 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 - - Methods - ------- - start() - Restarts paused timer - end() - Ends timer - pause() - Pauses timer - update(time_remaining: Optional[int] = None, status: Optional[str] = None) - Updates timer with new time remaining and/or status + """ + 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 @@ -577,16 +822,14 @@ def update(self, *, time_remaining: Optional[int] = None, Accepts only KW args - Parameters - ---------- - time_remaining : int - Time remaining on timer in seconds - status : str - Status of timer, can be active, paused, or done + Parameters: + time_remaining : int + Time remaining on timer in seconds + status : str + Status of timer, can be active, paused, or done - Returns - ------- - None + Returns: + None """ if time_remaining is not None: self.time_remaining = time_remaining diff --git a/src/pyvesync/vesync.py b/src/pyvesync/vesync.py index 55c5a3a..ca8f4de 100644 --- a/src/pyvesync/vesync.py +++ b/src/pyvesync/vesync.py @@ -27,7 +27,26 @@ def object_factory(dev_type, config, manager) -> Tuple[str, VeSyncBaseDevice]: - """Get device type and instantiate class.""" + """Get device type and instantiate class. + + Pulls the device types from each module to determine the type of device and + instantiates the device object. + + Args: + dev_type (str): Device model type returned from API + config (dict): Device configuration from `VeSync.get_devices()` API call + manager (VeSync): VeSync manager object + + Returns: + Tuple[str, VeSyncBaseDevice]: Tuple of device type classification and + instantiated device object + + Note: + Device types are pulled from the `*_mods` attribute of each device module. + See [pyvesync.vesyncbulb.bulb_mods], [pyvesync.vesyncfan.fan_mods], + [pyvesync.vesyncoutlet.outlet_mods], [pyvesync.vesyncswitch.switch_mods], + and [pyvesync.vesynckitchen.kitchen_mods] for more information. + """ def fans(dev_type, config, manager): fan_cls = fan_mods.fan_modules[dev_type] # noqa: F405 fan_obj = getattr(fan_mods, fan_cls) @@ -88,7 +107,6 @@ class is instantiated, call `manager.login()` to log in to VeSync servers, to retrieve devices and update device details. Parameters: - ----------- username : str VeSync account username (usually email address) password : str @@ -100,8 +118,7 @@ class is instantiated, call `manager.login()` to log in to VeSync servers, redact : bool, optional Redact sensitive information in logs, by default True - Attributes - ---------- + Attributes: fans : list List of VeSyncFan objects for humidifiers and air purifiers outlets : list @@ -112,6 +129,14 @@ class is instantiated, call `manager.login()` to log in to VeSync servers, List of VeSyncBulb objects for smart bulbs kitchen : list List of VeSyncKitchen objects for smart kitchen appliances + dev_list : dict + Dictionary of device lists + token : str + VeSync API token + account_id : str + VeSync account ID + enabled : bool + True if logged in to VeSync, False if not """ self.debug = debug if debug: # pragma: no cover @@ -277,7 +302,11 @@ def set_dev_id(devices: list) -> list: return devices def process_devices(self, dev_list: list) -> bool: - """Instantiate Device Objects.""" + """Instantiate Device Objects. + + Internal method run by `get_devices()` to instantiate device objects. + + """ devices = VeSync.set_dev_id(dev_list) num_devices = 0 @@ -315,7 +344,8 @@ def process_devices(self, dev_list: list) -> bool: return True def get_devices(self) -> bool: - """Return tuple listing outlets, switches, and fans of devices.""" + """Return tuple listing outlets, switches, and fans of devices. This is an internal method + called by `update()`""" if not self.enabled: return False @@ -346,9 +376,7 @@ def login(self) -> bool: Username and password are provided when class is instantiated. - Returns - ------- - bool + Returns: True if login successful, False if not """ user_check = isinstance(self.username, str) and len(self.username) > 0 @@ -394,9 +422,8 @@ def update(self) -> None: are stored in the instance attributes `outlets`, `switches`, `fans`, and `bulbs`. The `_device_list` attribute is a dictionary of these attributes. - Returns - ------- - None + Returns: + None """ if self.device_time_check(): @@ -414,13 +441,13 @@ def update(self) -> None: self.last_update_ts = time.time() def update_energy(self, bypass_check=False) -> None: - """Fetch updated energy information about devices.""" + """Fetch updated energy information for outlet devices.""" if self.outlets: for outlet in self.outlets: outlet.update_energy(bypass_check) def update_all_devices(self) -> None: - """Run get_details() for each device.""" + """Run `get_details()` for each device and update state.""" devices = list(self._dev_list.keys()) for dev in chain(*devices): dev.get_details() diff --git a/src/pyvesync/vesyncbasedevice.py b/src/pyvesync/vesyncbasedevice.py index 4353618..b87344a 100644 --- a/src/pyvesync/vesyncbasedevice.py +++ b/src/pyvesync/vesyncbasedevice.py @@ -1,16 +1,53 @@ """Base class for all VeSync devices.""" - +from __future__ import annotations import logging import json -from typing import Optional, Union +from typing import Optional, Union, TYPE_CHECKING from pyvesync.helpers import Helpers as helper logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from pyvesync import VeSync + class VeSyncBaseDevice: - """Properties shared across all VeSync devices.""" + """Properties shared across all VeSync devices. + + Base class for all VeSync devices. + + Parameters: + details (dict): Device details from API call. + manager (VeSync): Manager object for API calls. + + Attributes: + device_name (str): Name of device. + device_image (str): URL for device image. + cid (str): Device ID. + connection_status (str): Connection status of device. + 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. + mode (str): Mode of device. + speed (Union[str, int]): Speed of device. + extension (dict): Extension of device, not used. + 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. + config (dict): Configuration of device, including firmware version + device_status (str): Status of device, on or off. - def __init__(self, details: dict, manager): + Methods: + is_on(): Return True if device is on. + firmware_update(): Return True if firmware update available. + display(): Print formatted device info to stdout. + displayJSON(): JSON API for device details. + """ + + def __init__(self, details: dict, manager: VeSync) -> None: """Initialize VeSync device base class.""" self.manager = manager if 'cid' in details and details['cid'] is not None: @@ -105,7 +142,22 @@ def get_pid(self) -> None: self.pid = r.get('result', {}).get('pid') def display(self) -> None: - """Print formatted device info to stdout.""" + """Print formatted device info to stdout. + + Returns: + None + + Example: + ``` + Device Name:..................Living Room Lamp + Model:........................ESL100 + Subdevice No:.................0 + Status:.......................on + Online:.......................online + Type:.........................wifi + CID:..........................1234567890abcdef + ``` + """ disp = [ ('Device Name:', self.device_name), ('Model: ', self.device_type), @@ -122,7 +174,24 @@ def display(self) -> None: print(f'{line[0]:.<30} {line[1]}') def displayJSON(self) -> str: # pylint: disable=invalid-name - """JSON API for device details.""" + """JSON API for device details. + + Returns: + str: JSON formatted string of device details. + + Example: + ``` + { + "Device Name": "Living Room Lamp", + "Model": "ESL100", + "Subdevice No": "0", + "Status": "on", + "Online": "online", + "Type": "wifi", + "CID": "1234567890abcdef" + } + ``` + """ return json.dumps( { 'Device Name': self.device_name, diff --git a/src/pyvesync/vesyncbulb.py b/src/pyvesync/vesyncbulb.py index 9929eec..cc6ec24 100644 --- a/src/pyvesync/vesyncbulb.py +++ b/src/pyvesync/vesyncbulb.py @@ -1,18 +1,51 @@ -"""Etekcity/Valceno Smart Light Bulbs.""" +"""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 + +Attributes: + feature_dict (dict): Dictionary of bulb models and their supported features. + Defines the class to use for each bulb model and the list of features + bulb_modules (dict): Dictionary of bulb models as keys and their associated classes as string values. + +Note: + The bulb module is built from the `feature_dict` dictionary and used by the `vesync.object_factory` and tests + to determine the class to instantiate for each bulb model. + +Examples: + The following example shows the structure of the `feature_dict` dictionary: + ```python + + feature_dict = { + 'ESL100MC': { # device_type attribute + 'module': 'VeSyncBulbESL100MC', # String name of the class to instantiate + 'features': ['dimmable', 'rgb_shift'], # List of supported features + 'color_model': 'rgb' # Color model used by the bulb (rgb, hsv, none) + } + } + ``` + +""" +from __future__ import annotations import logging import json -from typing import Union, Dict, Optional, NamedTuple +from typing import Union, Dict, Optional, NamedTuple, TYPE_CHECKING from abc import ABCMeta, abstractmethod from pyvesync.helpers import Helpers as helpers, Color from pyvesync.vesyncbasedevice import VeSyncBaseDevice +if TYPE_CHECKING: + from pyvesync import VeSync + logger = logging.getLogger(__name__) NUMERIC_T = Optional[Union[int, float, str]] -# Possible features - dimmable, color_temp, rgb_shift feature_dict: dict = { 'ESL100': { @@ -53,12 +86,25 @@ def pct_to_kelvin(pct: float, max_k: int = 6500, min_k: int = 2700) -> float: class VeSyncBulb(VeSyncBaseDevice): - """Base class for VeSync Bulbs.""" + """Base class for VeSync Bulbs. + + Abstract base class to provide methods for controlling and + getting details of VeSync bulbs. Inherits from [`VeSyncBaseDevice`][pyvesync.vesyncbasedevice.VeSyncBaseDevice]. + + Attributes: + brightness (int): Brightness of bulb (0-100). + color_temp_kelvin (int): White color temperature of bulb in Kelvin. + color_temp_pct (int): White color temperature of bulb in percent (0-100). + color_hue (float): Color hue of bulb (0-360). + color_saturation (float): Color saturation of bulb in percent (0-100). + color_value (float): Color value of bulb in percent (0-100). + color (Color): Color of bulb in the form of a dataclass with two named tuple attributes - `hsv` & `rgb`. See [pyvesync.helpers.Color][]. + """ __metaclass__ = ABCMeta def __init__(self, details: Dict[str, Union[str, list]], - manager): + manager: VeSync) -> None: """Initialize VeSync smart bulb base class.""" super().__init__(details, manager) self._brightness = int(0) @@ -85,54 +131,107 @@ def __init__(self, details: Dict[str, Union[str, list]], @property def brightness(self) -> int: - """Return brightness of vesync bulb.""" + """Return brightness of vesync bulb. + + Returns: + int: Brightness of bulb (0-100). + """ if self.dimmable_feature and self._brightness is not None: return self._brightness return 0 @property def color_temp_kelvin(self) -> int: - """Return white color temperature of bulb in Kelvin.""" + """Return white color temperature of bulb in Kelvin. + + Converts the color temperature in percent to Kelvin using + the `pct_to_kelvin` function. + + Returns: + int: White color temperature of bulb in Kelvin (2700 - 6500). + + Notes: + This returns 0 for bulbs that do not have color temperature support. + """ if self.color_temp_feature and self._color_temp is not None: return int(pct_to_kelvin(self._color_temp)) return 0 @property def color_temp_pct(self) -> int: - """Return white color temperature of bulb in percent (0-100).""" + """Return white color temperature of bulb in percent (0-100). + + Subclasses that use this method, should calculate the color temeprature + in percent regardless of how the API returns the value. + """ if self.color_temp_feature and self._color_temp is not None: return int(self._color_temp) return 0 @property def color_hue(self) -> float: - """Return color hue of bulb. (from 0 to 360).""" + """Return color hue (HSV colorspace) of bulb. + + Returns hue from the `color` attribute. (0-360) + + Returns: + float: Color hue of bulb in HSV colorspace. + + Notes: + This returns 0 for bulbs that do not have color support. + """ if self.rgb_shift_feature and self._color is not None: return self._color.hsv.hue return 0 @property def color_saturation(self) -> float: - """Return color saturation of bulb in percent (0-100).""" + """Return color saturation (HSV colorspace) of bulb in percent. + + Return saturation from the `color` attribute (0-100). + + Returns: + float: Color saturation of bulb in percent (0-100). + + Notes: + This returns 0 for bulbs that do not have color + """ if self.rgb_shift_feature and self._color is not None: return self._color.hsv.saturation return 0 @property def color_value(self) -> float: - """Return color value of bulb in percent (0-100).""" + """Return color value (HSV colorspace) of bulb in percent. + + Returns the color from from the `color` attribute (0-100). + + Returns: + float: Color value of bulb in percent (0-100). + + Notes: + This returns 0 for bulbs that do not have color support. + """ if self.rgb_shift_feature and self._color is not None: return self._color.hsv.value return 0 @property def color(self) -> Optional[Color]: - """Return color of bulb in the form of a dataclass with two attributes. + """Set color property based on rgb or hsv values. - self.color.hsv -> (NamedTuple) Hue: float 0-360, - Saturation: float 0-100 and Value: float 0-100 - self.color.rgb -> (NamedTuple) Red: float 0-255, - Green: float 0-255 and Blue: float 0-255 + Pass either red, green, blue or hue, saturation, value. + + Args: + red (float): Red value of RGB color, 0-255 + green (float): Green value of RGB color, 0-255 + blue (float): Blue value of RGB color, 0-255 + hue (float): Hue value of HSV color, 0-360 + saturation (float): Saturation value of HSV color, 0-100 + value (float): Value (brightness) value of HSV color 0-100 + + Returns: + Color: Color dataclass with hsv and rgb named tuple attributes. """ if self.rgb_shift_feature is True and self._color is not None: return self._color @@ -145,26 +244,40 @@ def color(self, red: Optional[float] = None, hue: Optional[float] = None, saturation: Optional[float] = None, value: Optional[float] = None) -> None: + """Set color property based on rgb or hsv values.""" self._color = Color(red=red, green=green, blue=blue, hue=hue, saturation=saturation, value=value) @property def color_hsv(self) -> Optional[NamedTuple]: - """Return color of bulb in hsv.""" + """Return color of bulb as [hsv named tuple][pyvesync.helpers.HSV]. + + Notes: + Returns `None` for bulbs that do not have color support. + """ if self.rgb_shift_feature is True and self._color is not None: return self._color.hsv return None @property def color_rgb(self) -> Optional[NamedTuple]: - """Return color of bulb in rgb.""" + """Return color of bulb as [rgb named tuple][pyvesync.helpers.RGB]. + + Notes: + Returns `None` for bulbs that do not have color support. + """ if self.rgb_shift_feature is True and self._color is not None: return self._color.rgb return None @property def color_mode(self) -> Optional[str]: - """Return color mode of bulb.""" # { white, hsv } + """Return color mode of bulb. Possible values are none, hsv or rgb. + + Notes: + This is a read-only property. Color mode is defined in + the [`feature_dict`][pyvesync.vesyncbulb.feature_dict]. + """ if self.rgb_shift_feature and self._color_mode is not None: return str(self._color_mode) return None @@ -178,14 +291,22 @@ def dimmable_feature(self) -> bool: @property def color_temp_feature(self) -> bool: - """Return true if bulb supports white color temperature changes.""" + """Checks if the device has the ability to change color temperature. + + Returns: + bool: True if the device supports changing color temperature. + """ if self.features is not None and 'color_temp' in self.features: return True return False @property def rgb_shift_feature(self) -> bool: - """Return True if bulb supports changing color (RGB).""" + """Checks if the device is multicolor. + + Returns: + bool: True if the device supports changing color. + """ if self.features is not None and 'rgb_shift' in self.features: return True return False @@ -275,45 +396,114 @@ def _validate_any(value: Union[int, float, str], @abstractmethod def set_status(self) -> bool: - """Set vesync bulb attributes(brightness, color_temp, etc).""" + """Set vesync bulb attributes(brightness, color_temp, etc). + + This is a helper function that is called by the direct `set_*` methods, + such as `set_brightness`, `set_rgb`, `set_hsv`, etc. + + Returns: + bool : True if successful, False otherwise. + """ @abstractmethod def get_details(self) -> None: - """Get vesync bulb details.""" + """Get vesync bulb details. + + This is a legacy function to update devices, **updates should be + called by `update()`** + + Returns: + None + """ @abstractmethod - def _interpret_apicall_result(self, response) -> None: + def _interpret_apicall_result(self, response: dict) -> None: """Update bulb status from any api call response.""" @abstractmethod def toggle(self, status: str) -> bool: - """Toggle vesync lightbulb.""" + """Toggle mode of vesync lightbulb. + + Helper function called by `turn_on()` and `turn_off()`. + + Args: + status (str): 'on' or 'off' + + Returns: + bool: True if successful, False otherwise. + """ @abstractmethod def get_config(self) -> None: - """Call api to get configuration details and firmware.""" + """Call api to get configuration details and firmware. + + Populates the `self.config` attribute with the response. + + Returns: + None + + Note: + The configuration attribute `self.config` is structured as follows: + ```python + { + 'current_firmware_version': '1.0.0', + 'latest_firmware_version': '1.0.0', + 'maxPower': '560', + 'threshold': '1000', + 'power_protection': 'on', + 'energy_saving_status': 'on' + } + ``` + """ pass - def set_hsv(self, hue, saturation, value): + def set_hsv(self, + hue: NUMERIC_T, + saturation: NUMERIC_T, + value: NUMERIC_T + ) -> Optional[bool]: """Set HSV if supported by bulb. - Hue 0-360, Saturation 0-100, Value 0-100. + Args: + hue (NUMERIC_T): Hue 0-360 + saturation (NUMERIC_T): Saturation 0-100 + value (NUMERIC_T): Value 0-100 + + Returns: + bool: True if successful, False otherwise. """ if self.rgb_shift_feature is False: logger.debug("HSV not supported by bulb") return False + return True - def set_rgb(self, red: Optional[float] = None, - green: Optional[float] = None, - blue: Optional[float] = None) -> bool: - """Set RGB if supported by bulb. Red 0-255, Green 0-255, Blue 0-255.""" + def set_rgb(self, red: NUMERIC_T = None, + green: NUMERIC_T = None, + blue: NUMERIC_T = None + ) -> bool: + """Set RGB if supported by bulb. + + Args: + red (NUMERIC_T): Red 0-255 + green (NUMERIC_T): green 0-255 + blue (NUMERIC_T): blue 0-255 + + Returns: + bool: True if successful, False otherwise. + """ if self.rgb_shift_feature is False: logger.debug("RGB not supported by bulb") return False return True def turn_on(self) -> bool: - """Turn on vesync bulbs.""" + """Turn on vesync bulbs. + + Calls `toggle('on')`. + + Returns: + bool : True if successful, False otherwise. + """ if self.toggle('on'): self.device_status = 'on' return True @@ -321,7 +511,13 @@ def turn_on(self) -> bool: return False def turn_off(self) -> bool: - """Turn off vesync bulbs.""" + """Turn off vesync bulbs. + + Calls `toggle('off')`. + + Returns: + bool : True if successful, False otherwise. + """ if self.toggle('off'): self.device_status = 'off' return True @@ -329,7 +525,15 @@ def turn_off(self) -> bool: return False def update(self) -> None: - """Update bulb details.""" + """Update bulb details. + + Calls `get_details()` method to retrieve status from API and + update the bulb attributes. `get_details()` is overriden by subclasses + to hit the respective API endpoints. + + Returns: + None + """ self.get_details() def display(self) -> None: @@ -392,15 +596,49 @@ def color_value_hsv(self) -> Optional[NamedTuple]: class VeSyncBulbESL100MC(VeSyncBulb): - """Etekcity ESL100 Multi Color Bulb.""" + """Etekcity ESL100 Multi Color Bulb device instance. + + Inherits from [VeSyncBulb][pyvesync.vesyncbulb.VeSyncBulb] + and [VeSyncBaseDevice][pyvesync.vesyncbasedevice.VeSyncBaseDevice]. + + Attributes: + device_status (str): Status of bulb, either 'on' or 'off'. + connection_status (str): Connection status of bulb, either 'online' or 'offline'. + details (dict): Dictionary of bulb state details. + brightness (int): Brightness of bulb (0-100). + color_temp_kelvin (int): White color temperature of bulb in Kelvin. + color_temp_pct (int): White color temperature of bulb in percent (0-100). + color_hue (float): Color hue of bulb (0-360). + color_saturation (float): Color saturation of bulb in percent (0-100). + color_value (float): Color value of bulb in percent (0-100). + color (Color): Color of bulb in the form of a dataclass with + two named tuple attributes - `hsv` & `rgb`. See [pyvesync.helpers.Color][]. + + Notes: + The `self.details` dictionary is structured as follows: + ```python + >>> self.details + { + 'brightness': 0, + 'colorMode': 'color', + 'red': 0, + 'green': 0, + 'blue': 0 + } + ``` + """ - def __init__(self, details: Dict[str, Union[str, list]], manager): - """Instantiate ESL100MC Multicolor Bulb.""" + def __init__(self, details: Dict[str, Union[str, list]], manager: VeSync) -> None: + """Instantiate ESL100MC Multicolor Bulb. + + Args: + details (dict): Dictionary of bulb state details. + manager (VeSync): Manager class used to make API calls + """ super().__init__(details, manager) self.details: dict = {} def get_details(self) -> None: - """Get ESL100MC Details.""" head = helpers.bypass_header() body = helpers.bypass_body_v2(self.manager) body['cid'] = self.cid @@ -440,33 +678,61 @@ def _interpret_apicall_result(self, response: dict): return True def set_brightness(self, brightness: int) -> bool: - """Set brightness of bulb.""" + """Set brightness of bulb. + + Calls the `set_status` method with the brightness value. + + Args: + brightness (int): Brightness of bulb (0-100). + + Returns: + bool: True if successful, False otherwise. + """ return self.set_status(brightness=brightness) - def set_rgb_color(self, red: float, green: float, blue: float) -> bool: - """Set RGB Color of bulb.""" + def set_rgb_color(self, red: NUMERIC_T, green: NUMERIC_T, blue: NUMERIC_T) -> bool: + """DEPRECIATED, USE `set_rgb()`.""" return self.set_status(red=red, green=green, blue=blue) - def set_rgb(self, red: Optional[float] = None, - green: Optional[float] = None, - blue: Optional[float] = None) -> bool: - """Set RGB Color of bulb.""" + def set_rgb(self, red: NUMERIC_T = None, + green: NUMERIC_T = None, + blue: NUMERIC_T = None) -> bool: return self.set_status(red=red, green=green, blue=blue) - def set_hsv(self, hue, saturation, value): - """Set HSV Color of bulb.""" + def set_hsv(self, + hue: NUMERIC_T, + saturation: NUMERIC_T, + value: NUMERIC_T + ) -> Optional[bool]: rgb = Color(hue=hue, saturation=saturation, value=value).rgb return self.set_status(red=rgb.red, green=rgb.green, blue=rgb.blue) def enable_white_mode(self) -> bool: - """Enable white mode on bulb.""" + """Enable white mode on bulb. + + Returns: + bool: True if successful, False otherwise. + """ return self.set_status(brightness=100) def set_status(self, brightness: Optional[NUMERIC_T] = None, red: Optional[NUMERIC_T] = None, green: Optional[NUMERIC_T] = None, blue: Optional[NUMERIC_T] = None) -> bool: - """Set status of VeSync ESL100MC.""" + """Set color of VeSync ESL100MC. + + Brightness or RGB values must be provided. If RGB values are provided, + brightness is ignored. + + Args: + brightness (int): Brightness of bulb (0-100). + red (int): Red value of RGB color, 0-255. + green (int): Green value of RGB color, 0-255. + blue (int): 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 = self._validate_rgb(red, green, blue) @@ -529,7 +795,6 @@ def set_status(self, brightness: Optional[NUMERIC_T] = None, return True def toggle(self, status: str) -> bool: - """Toggle bulb status.""" if status == 'on': turn_on = True elif status == 'off': @@ -566,15 +831,28 @@ def toggle(self, status: str) -> bool: class VeSyncBulbESL100(VeSyncBulb): - """Object to hold VeSync ESL100 light bulb.""" + """Object to hold VeSync ESL100 light bulb. + + This bulb only has the dimmable feature. + + Attributes: + details (dict): Dictionary of bulb state details. + brightness (int): Brightness of bulb (0-100). + device_status (str): Status of bulb (on/off). + connection_status (str): Connection status of bulb (online/offline). + """ def __init__(self, details: dict, manager) -> None: - """Initialize Etekcity ESL100 Dimmable Bulb.""" + """Initialize Etekcity ESL100 Dimmable Bulb. + + Args: + details (dict): Dictionary of bulb state details. + manager (VeSync): Manager class used to make API calls + """ super().__init__(details, manager) self.details: dict = {} def get_details(self) -> None: - """Get details of dimmable bulb.""" body = helpers.req_body(self.manager, 'devicedetail') body['uuid'] = self.uuid r, _ = helpers.call_api( @@ -592,7 +870,6 @@ def get_details(self) -> None: logger.debug('Error getting %s details', self.device_name) def get_config(self) -> None: - """Get configuration of dimmable bulb.""" body = helpers.req_body(self.manager, 'devicedetail') body['method'] = 'configurations' body['uuid'] = self.uuid @@ -610,7 +887,6 @@ def get_config(self) -> None: logger.debug('Error getting %s config info', self.device_name) def toggle(self, status) -> bool: - """Toggle dimmable bulb.""" body = helpers.req_body(self.manager, 'devicestatus') body['uuid'] = self.uuid body['status'] = status @@ -626,7 +902,14 @@ def toggle(self, status) -> bool: return False def set_brightness(self, brightness: int) -> bool: - """Set brightness of dimmable bulb.""" + """Set brightness of dimmable bulb. + + Args: + brightness (int): Brightness of bulb (0-100). + + Returns: + bool: True if successful, False otherwise. + """ if not self.dimmable_feature: logger.debug('%s is not dimmable', self.device_name) return False @@ -658,12 +941,11 @@ def set_brightness(self, brightness: int) -> bool: class VeSyncBulbESL100CW(VeSyncBulb): """VeSync Tunable and Dimmable White Bulb.""" - def __init__(self, details, manager): + def __init__(self, details, manager: VeSync) -> None: """Initialize Etekcity Tunable white bulb.""" super().__init__(details, manager) def get_details(self) -> None: - """Get details of tunable bulb.""" body = helpers.req_body(self.manager, 'bypass') body['cid'] = self.cid body['jsonCmd'] = {'getLightStatus': 'get'} @@ -700,7 +982,6 @@ def _interpret_apicall_result(self, response) -> None: self._color_temp = response.get('colorTempe', 0) def get_config(self) -> None: - """Get configuration and firmware info of tunable bulb.""" body = helpers.req_body(self.manager, 'bypass_config') body['uuid'] = self.uuid @@ -717,7 +998,6 @@ def get_config(self) -> None: logger.debug('Error getting %s config info', self.device_name) def toggle(self, status) -> bool: - """Toggle tunable bulb.""" if status not in ('on', 'off'): logger.debug('Invalid status %s', status) return False @@ -814,12 +1094,11 @@ def set_color_temp(self, color_temp: int) -> bool: class VeSyncBulbValcenoA19MC(VeSyncBulb): """VeSync Multicolor Bulb.""" - def __init__(self, details, manager): + def __init__(self, details: dict, manager) -> None: """Initialize Multicolor bulb.""" super().__init__(details, manager) def get_details(self) -> None: - """Get details of multicolor bulb.""" body = helpers.req_body(self.manager, 'bypassV2') body['cid'] = self.cid body['configModule'] = self.config_module @@ -839,7 +1118,7 @@ def get_details(self) -> None: return self._interpret_apicall_result(r) - def _interpret_apicall_result(self, response) -> None: + def _interpret_apicall_result(self, response: dict) -> None: if response.get('result', {}).get('result') is not None: innerresult = response.get('result', {}).get('result') self.connection_status = 'online' @@ -872,7 +1151,6 @@ def _interpret_apicall_result(self, response) -> None: ) def get_config(self) -> None: - """Get configuration and firmware info of multicolor bulb.""" body = helpers.req_body(self.manager, 'bypass') body['method'] = 'configurations' body['uuid'] = self.uuid @@ -909,7 +1187,6 @@ def __build_config_dict(self, conf_dict: Dict[str, str]) -> None: conf_dict.get('ownerShip', False)) def toggle(self, status: str) -> bool: - """Toggle multicolor bulb.""" body = helpers.req_body(self.manager, 'bypassV2') if status == 'off': status_bool = False @@ -948,7 +1225,6 @@ def toggle(self, status: str) -> bool: def set_rgb(self, red: NUMERIC_T = None, green: NUMERIC_T = None, blue: NUMERIC_T = None) -> bool: - """Set RGB - red, green & blue 0-255.""" new_color = Color(red=red, green=green, blue=blue).hsv return self.set_hsv(hue=new_color.hue, saturation=new_color.saturation, @@ -982,7 +1258,6 @@ def set_color_mode(self, color_mode: str) -> bool: def set_hsv(self, hue: NUMERIC_T = None, saturation: NUMERIC_T = None, value: NUMERIC_T = None) -> bool: - """Set HSV Values.""" arg_dict = {"hue": hue, "saturation": saturation, "value": value} if hue is not None: hue_update: NUMERIC_T = self._validate_any(hue, 0, 360, 360) @@ -1041,25 +1316,16 @@ def set_status(self, No arguments turns bulb on. - Parameters - ---------- - brightness : int, optional - brightness between 0 and 100, by default None - color_temp : int, optional - color temperature between 0 and 100, by default None - color_mode : str, optional - color mode hsv or white, by default None - color_hue : float, optional - color hue between 0 and 360, by default None - color_saturation : float, optional - color saturation between 0 and 100, by default None - color_value : float, optional - color value between 0 and 100, by default None - - Returns - ------- - bool - True if call was successful, False otherwise + 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 """ arg_list = ['brightness', 'color_temp', 'color_saturation', 'color_hue', 'color_mode', 'color_value'] diff --git a/src/pyvesync/vesyncfan.py b/src/pyvesync/vesyncfan.py index 6272e29..c287a11 100644 --- a/src/pyvesync/vesyncfan.py +++ b/src/pyvesync/vesyncfan.py @@ -161,7 +161,11 @@ def model_dict() -> dict: - """Build purifier and humidifier model dictionary.""" + """Build purifier and humidifier model dictionary. + + Internal function to build a dictionary of device models and their associated + classes. Used by the `vesync.object_factory` to determine the class to instantiate. + """ model_modules = {} for dev_dict in {**air_features, **humid_features}.values(): for model in dev_dict['models']: @@ -170,7 +174,19 @@ def model_dict() -> dict: def model_features(dev_type: str) -> dict: - """Get features from device type.""" + """Get features from device type. + + Used by classes to determine the features of the device. + + Parameters: + dev_type (str): Device model type + + Returns: + dict: Device dictionary + + Raises: + ValueError: Device not configured in `air_features` or `humid_features` + """ for dev_dict in {**air_features, **humid_features}.values(): if dev_type in dev_dict['models']: return dev_dict @@ -186,55 +202,44 @@ def model_features(dev_type: str) -> dict: class VeSyncAirBypass(VeSyncBaseDevice): - """Base class for Levoit Purifier Bypass API Calls.""" + """Initialize air purifier devices. - def __init__(self, details: Dict[str, list], manager): - """Initialize air purifier devices. - - Instantiated by VeSync manager object. Inherits from - VeSyncBaseDevice class. - - Arguments - ---------- - details : dict - Dictionary of device details - manager : VeSync - Instantiated VeSync object used to make API calls - - Attributes - ---------- - modes : list - List of available operation modes for device - air_quality_feature : bool - True if device has air quality sensor - details : dict - Dictionary of device details - timer : Timer - Timer object for device, None if no timer exists. See `Timer` class - config : dict - Dictionary of device configuration - - Notes - ----- + Instantiated by VeSync manager object. Inherits from + VeSyncBaseDevice class. + + Parameters: + details (dict): Dictionary of device details + manager (VeSync): Instantiated VeSync object used to make API calls + + Attributes: + modes (list): List of available operation modes for device + air_quality_feature (bool): True if device has air quality sensor + details (dict): Dictionary of device details + timer (Timer): Timer object for device, None if no timer exists. See + [pyveysnc.helpers.Timer][`Timer`] class + config (dict): Dictionary of device configuration + + Notes: The `details` attribute holds device information that is updated when the `update()` method is called. An example of the `details` attribute: - >>> { - >>> 'filter_life': 0, - >>> 'mode': 'manual', - >>> 'level': 0, - >>> 'display': False, - >>> 'child_lock': False, - >>> 'night_light': 'off', - >>> 'air_quality': 0 # air quality level - >>> 'air_quality_value': 0, # PM2.5 value from device, - >>> 'display_forever': False - >>> } - - See Also - -------- - VeSyncBaseDevice : Parent class for all VeSync devices + ```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 + } + ``` + """ - """ + def __init__(self, details: Dict[str, list], manager): + """Initialize VeSync Air Purifier Bypass Base Class.""" super().__init__(details, manager) self.enabled = True self._config_dict = model_features(self.device_type) @@ -268,10 +273,31 @@ def __init__(self, details: Dict[str, list], manager): def build_api_dict(self, method: str) -> Tuple[Dict, Dict]: """Build device api body dictionary. - standard modes are: ['getPurifierStatus', 'setSwitch', - 'setNightLight', - 'setLevel', 'setPurifierMode', 'setDisplay', - 'setChildLock'] + This method is used internally as a helper function to build API + requests. + + Parameters: + method (str): API method to call + + Returns: + Tuple(Dict, Dict): Tuple of headers and body dictionaries + + Notes: + Possible methods are: + + 1. 'getPurifierStatus' + 2. 'setSwitch' + 3. 'setNightLight' + 4. 'setLevel' + 5. 'setPurifierMode' + 6. 'setDisplay' + 7. 'setChildLock' + 8. 'setIndicatorLight' + 9. 'getTimer' + 10. 'addTimer' + 11. 'delTimer' + 12. 'resetFilter' + """ modes = ['getPurifierStatus', 'setSwitch', 'setNightLight', 'setLevel', 'setPurifierMode', 'setDisplay', @@ -291,7 +317,45 @@ def build_api_dict(self, method: str) -> Tuple[Dict, Dict]: return head, body def build_purifier_dict(self, dev_dict: dict) -> None: - """Build Bypass purifier status dictionary.""" + """Build Bypass purifier status dictionary. + + Populates `self.details` and instance variables with device details. + + Args: + dev_dict (dict): Dictionary of device details from API + + Returns: + None + + Examples: + >>> dev_dict = { + ... 'enabled': True, + ... 'filter_life': 0, + ... 'mode': 'manual', + ... 'level': 0, + ... 'display': False, + ... 'child_lock': False, + ... 'night_light': 'off', + ... 'display': False, + ... 'display_forever': False, + ... 'air_quality_value': 0, + ... 'air_quality': 0 + ... } + >>> build_purifier_dict(dev_dict) + >>> print(self.details) + { + 'filter_life': 0, + 'mode': 'manual', + 'level': 0, + 'display': False, + 'child_lock': False, + 'night_light': 'off', + 'display': False, + 'display_forever': False, + 'air_quality_value': 0, + 'air_quality': 0 + } + """ self.enabled = dev_dict.get('enabled', False) if self.enabled: self.device_status = 'on' @@ -312,7 +376,16 @@ def build_purifier_dict(self, dev_dict: dict) -> None: self.details['air_quality'] = dev_dict.get('air_quality', 0) def build_config_dict(self, conf_dict: Dict[str, str]) -> None: - """Build configuration dict for Bypass purifier.""" + """Build configuration dict for Bypass purifier. + + Used by the `update()` method to populate the `config` attribute. + + Args: + conf_dict (dict): Dictionary of device configuration + + Returns: + None + """ self.config['display'] = conf_dict.get('display', False) self.config['display_forever'] = conf_dict.get('display_forever', False) @@ -367,25 +440,21 @@ def get_timer(self) -> Optional[Timer]: Returns Timer object if timer is running, None if no timer is running. - Arguments - ---------- - None + Args: + None - Returns - ------- - Timer or None + Returns: + Timer | None : Timer object if timer is running, None if no timer is running - Notes - ----- - Timer object tracks the time remaining based on the last update. Timer - properties include `status`, `time_remaining`, `duration`, `action`, - `paused` and `done`. The methods `start()`, `end()` and `pause()` - are available but should be called through the purifier object - to update through the API. + Notes: + Timer object tracks the time remaining based on the last update. Timer + properties include `status`, `time_remaining`, `duration`, `action`, + `paused` and `done`. The methods `start()`, `end()` and `pause()` + are available but should be called through the purifier object + to update through the API. - See Also - -------- - Timer : Timer object used to track device timers + See Also: + [pyvesync.helpers.Time][`Timer`] : Timer object used to hold status of timer """ head, body = self.build_api_dict('getTimer') @@ -431,14 +500,11 @@ def get_timer(self) -> Optional[Timer]: def set_timer(self, timer_duration: int) -> bool: """Set timer for Purifier. - Arguments - ---------- - timer_duration: int - Duration of timer in seconds + Args: + timer_duration (int): Duration of timer in seconds - Returns - ------- - bool + Returns: + bool : True if timer is set, False if not """ if self.device_status != 'on': @@ -481,10 +547,8 @@ def clear_timer(self) -> bool: Returns True if no error is returned from API call. - Returns - ------- - bool - + Returns: + bool : True if timer is cleared, False if not """ self.get_timer() if self.timer is None: @@ -515,7 +579,16 @@ def clear_timer(self) -> bool: def change_fan_speed(self, speed=None) -> bool: - """Change fan speed based on levels in configuration dict.""" + """Change fan speed based on levels in configuration dict. + + If no value is passed, the next speed in the list is selected. + + Args: + speed (int, optional): Speed to set fan. Defaults to None. + + Returns: + bool : True if speed is set, False if not + """ speeds: list = self._config_dict.get('levels', []) current_speed = self.speed @@ -564,22 +637,24 @@ def child_lock_on(self) -> bool: return self.set_child_lock(True) def child_lock_off(self) -> bool: - """Turn Bypass child lock off.""" + """Turn Bypass child lock off. + + Returns: + bool : True if child lock is turned off, False if not + """ return self.set_child_lock(False) def set_child_lock(self, mode: bool) -> bool: """Set Bypass child lock. - Set child lock to on or off. + Set child lock to on or off. Internal method used by `child_lock_on` and + `child_lock_off`. - Arguments - ---------- - mode: bool - True to turn child lock on, False to turn off + Args: + mode (bool): True to turn child lock on, False to turn off - Returns - ------- - bool + Returns: + bool : True if child lock is set, False if not """ if mode not in (True, False): @@ -611,7 +686,11 @@ def set_child_lock(self, mode: bool) -> bool: return False def reset_filter(self) -> bool: - """Reset filter to 100%.""" + """Reset filter to 100%. + + Returns: + bool : True if filter is reset, False if not + """ if 'reset_filter' not in self._features: logger.debug("Filter reset not implemented for %s", self.device_type) return False @@ -639,14 +718,11 @@ def mode_toggle(self, mode: str) -> bool: Set purifier mode based on devices available modes. - Arguments - ---------- - mode: str - Mode to set purifier. Based on device modes in attribute `modes` + Args: + mode (str): Mode to set purifier. Based on device modes in attribute `modes` - Returns - ------- - bool + Returns: + bool : True if mode is set, False if not """ if mode.lower() not in self.modes: @@ -690,28 +766,56 @@ def mode_toggle(self, mode: str) -> bool: return False def manual_mode(self) -> bool: - """Set mode to manual.""" + """Set mode to manual. + + Calls method [pyvesync.VeSyncAirBypass.mode_toggle][`self.mode_toggle('manual')`] + to set mode to manual. + + Returns: + bool : True if mode is set, False if not + """ if 'manual' not in self.modes: logger.debug('%s does not have manual mode', self.device_name) return False return self.mode_toggle('manual') def sleep_mode(self) -> bool: - """Set sleep mode to on.""" + """Set sleep mode to on. + + Calls method [pyvesync.VeSyncAirBypass.mode_toggle][`self.mode_toggle('sleep')`] + + Returns: + bool : True if mode is set, False if not + """ if 'sleep' not in self.modes: logger.debug('%s does not have sleep mode', self.device_name) return False return self.mode_toggle('sleep') def auto_mode(self) -> bool: - """Set mode to auto.""" + """Set mode to auto. + + Calls method [pyvesync.VeSyncAirBypass.mode_toggle][`self.mode_toggle('sleep')`] + + Returns: + bool : True if mode is set, False if not + """ if 'auto' not in self.modes: logger.debug('%s does not have auto mode', self.device_name) return False return self.mode_toggle('auto') def toggle_switch(self, toggle: bool) -> bool: - """Toggle purifier on/off.""" + """Toggle purifier on/off. + + Helper method for `turn_on()` and `turn_off()` methods. + + Args: + toggle (bool): True to turn on, False to turn off + + Returns: + bool : True if purifier is toggled, False if not + """ if not isinstance(toggle, bool): logger.debug('Invalid toggle value for purifier switch') return False @@ -747,15 +851,36 @@ def toggle_switch(self, toggle: bool) -> bool: return False def turn_on(self) -> bool: - """Turn bypass Purifier on.""" + """Turn bypass Purifier on. + + Calls method [pyvesync.VeSyncAirBypass.toggle_switch][`self.toggle_switch(True)`] + + Returns: + bool : True if purifier is turned on, False if not + """ return self.toggle_switch(True) def turn_off(self): - """Turn Bypass Purifier off.""" + """Turn Bypass Purifier off. + + Calls method [pyvesync.VeSyncAirBypass.toggle_switch][`self.toggle_switch(False)`] + + Returns: + bool : True if purifier is turned off, False if not + """ return self.toggle_switch(False) def set_display(self, mode: bool) -> bool: - """Toggle display on/off.""" + """Toggle display on/off. + + Called by `turn_on_display()` and `turn_off_display()` methods. + + Args: + mode (bool): True to turn display on, False to turn off + + Returns: + bool : True if display is toggled, False if not + """ if not isinstance(mode, bool): logger.debug("Mode must be True or False") return False @@ -780,15 +905,36 @@ def set_display(self, mode: bool) -> bool: return False def turn_on_display(self) -> bool: - """Turn Display on.""" + """Turn Display on. + + Calls method [pyvesync.VeSyncAirBypass.set_display][`self.set_display(True)`] + + Returns: + bool : True if display is turned on, False if not + """ return self.set_display(True) def turn_off_display(self): - """Turn Display off.""" + """Turn Display off. + + Calls method [pyvesync.VeSyncAirBypass.set_display][`self.set_display(False)`] + + Returns: + bool : True if display is turned off, False if not + """ return self.set_display(False) def set_night_light(self, mode: str) -> bool: - """Set night list - on, off or dim.""" + """Set night light. + + Possible modes are on, off or dim. + + Args: + mode (str): Mode to set night light + + Returns: + bool : True if night light is set, False if not + """ if mode.lower() not in ['on', 'off', 'dim']: logger.debug('Invalid nightlight mode used (on, off or dim)- %s', mode) @@ -826,7 +972,7 @@ def air_quality(self): @property def fan_level(self): - """Get current fan level (1-3).""" + """Get current fan level.""" try: speed = int(self.speed) except ValueError: @@ -843,26 +989,50 @@ def filter_life(self) -> int: @property def display_state(self) -> bool: - """Get display state.""" + """Get display state. + + See [pyvesync.VeSyncAirBypass.display_status][`self.display_status`] + """ return bool(self.details['display']) @property def screen_status(self) -> bool: - """Get display status.""" + """Get display status. + + Returns: + bool : True if display is on, False if off + """ return bool(self.details['display']) @property def child_lock(self) -> bool: - """Get child lock state.""" + """Get child lock state. + + Returns: + bool : True if child lock is enabled, False if not. + """ return bool(self.details['child_lock']) @property def night_light(self) -> str: - """Get night light state (on/dim/off).""" + """Get night light state. + + Returns: + str : Night light state (on, dim, off) + """ return str(self.details['night_light']) def display(self) -> None: - """Return formatted device info to stdout.""" + """Print formatted device info to stdout. + + Builds on the `display()` method from the `VeSyncBaseDevice` class. + + Returns: + None + + See Also: + [pyvesync.VeSyncBaseDevice.display][`VeSyncBaseDevice.display`] + """ super().display() disp = [ ('Mode: ', self.mode, ''), @@ -885,8 +1055,12 @@ def display(self) -> None: for line in disp: print(f'{line[0]:.<30} {line[1]} {line[2]}') - def displayJSON(self) -> str: - """Return air purifier status and properties in JSON output.""" + def displayJSON(self) -> str: # noqa: N802 + """Return air purifier status and properties in JSON output. + + Returns: + str : JSON formatted string of air purifier details + """ sup = super().displayJSON() sup_val = json.loads(sup) sup_val.update( @@ -912,7 +1086,25 @@ def displayJSON(self) -> str: class VeSyncAirBaseV2(VeSyncAirBypass): - """Levoit V2 Air Purifier Class.""" + """Levoit V2 Air Purifier Class. + + Inherits from VeSyncAirBypass and VeSyncBaseDevice class. + + Args: + details (dict): Dictionary of device details + manager (VeSync): Instantiated VeSync object + + Attributes: + set_speed_level (int): Set speed level for device + auto_prefences (list): List of auto preferences for device + modes (list): List of available operation modes for device + air_quality_feature (bool): True if device has air quality sensor + details (dict): Dictionary of device details + timer (Timer): Timer object for device, None if no timer exists. See + [pyveysnc.helpers.Timer][`Timer`] class + config (dict): Dictionary of device configuration + + """ def __init__(self, details: Dict[str, list], manager): """Initialize the VeSync Base API V2 Air Purifier Class.""" @@ -1099,7 +1291,14 @@ def toggle_switch(self, toggle: bool) -> bool: return False def set_child_lock(self, mode: bool) -> bool: - """Levoit 100S/200S set Child Lock.""" + """Levoit 100S/200S set Child Lock. + + Parameters: + mode (bool): True to turn child lock on, False to turn off + + Returns: + bool : True if successful, False if not + """ toggle_id = int(mode) head, body = self.build_api_dict('setChildLock') body['payload']['data'] = { @@ -1146,17 +1345,16 @@ def set_timer(self, timer_duration: int, action: str = 'off', method: str = 'powerSwitch') -> bool: """Set timer for Levoit 100S. - Parameters - ---------- - timer_duration : int - Timer duration in seconds. - action : str, optional - Action to perform, on or off, by default 'off' - method : str, optional - Method to use, by default 'powerSwitch' - TODO: Implement other methods - Returns - ------- - bool + Parameters: + timer_duration (int): + Timer duration in seconds. + action (str | None): + Action to perform, on or off, by default 'off' + method (str | None): + Method to use, by default 'powerSwitch' - TODO: Implement other methods + + Returns: + bool : True if successful, False if not """ if action not in ['on', 'off']: logger.debug('Invalid action for timer') @@ -1216,13 +1414,11 @@ def set_auto_preference(self, preference: str = 'default', room_size: int = 600) -> bool: """Set Levoit Vital 100S/200S auto mode. - Parameters - ---------- - preference : str, optional - Preference for auto mode, by default 'default' - options are: default, efficient, quiet - room_size : int, optional - Room size in square feet, by default 600 + Parameters: + preference (str | None): + 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_prefences: logger.debug("%s is invalid preference -" @@ -1252,11 +1448,11 @@ def set_auto_preference(self, preference: str = 'default', def change_fan_speed(self, speed=None) -> bool: """Change fan speed based on levels in configuration dict. - Parameters - ---------- - speed : int, optional - Speed to set based on levels in configuration dict, by default None - If None, will cycle through levels in configuration dict + The levels are defined in the configuration dict for the device. If no level is + passed, the next valid level will be used. If the current level is the last level. + + Parameters: + speed (int | None): Speed to set based on levels in configuration dict """ speeds: list = self._config_dict.get('levels', []) current_speed = self.set_speed_level or 0 @@ -1300,14 +1496,11 @@ def change_fan_speed(self, speed=None) -> bool: def mode_toggle(self, mode: str) -> bool: """Set Levoit 100S purifier mode. - Parameters - ---------- - mode : str - Mode to set purifier to, options are: auto, manual, sleep + Parameters: + mode (str): Mode to set purifier to, options are: auto, manual, sleep - Returns - ------- - bool + Returns: + bool : True if successful, False if not """ if mode.lower() not in self.modes: logger.debug('Invalid purifier mode used - %s', @@ -1680,14 +1873,12 @@ def get_details(self) -> None: def mode_toggle(self, mode: str) -> bool: """Set Levoit Tower Fan purifier mode. - Parameters - ---------- - mode : str - Mode to set purifier to, options are: auto, manual, sleep + Parameters: + mode : str + Mode to set purifier to, set by `config_dict` - Returns - ------- - bool + Returns: + bool : True if successful, False if not """ if mode.lower() not in [x.lower() for x in self.modes]: logger.debug('Invalid purifier mode used - %s', @@ -2647,8 +2838,12 @@ def drying_mode_seconds_remaining(self): @property def drying_mode_enabled(self): - """True if fan will stay on to dry the filters when humidifier is off, \ - False otherwise.""" + """Checks if drying mode is enabled. + + Returns: + bool: True if enabled, false if disabled + + """ enabled = self.details.get('drying_mode', {}).get('autoDryingSwitch') return None if enabled is None else bool(enabled) From 74df2af451c4270fc9059c0ad3f985868be3e4b8 Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Sun, 20 Oct 2024 18:31:09 -0400 Subject: [PATCH 2/4] Linting and Typing Start transition to Ruff and improve docstrings --- .pylintrc | 5 +- ruff.toml | 19 ++- setup.cfg | 1 + src/pyvesync/helpers.py | 135 ++++++++-------- src/pyvesync/vesync.py | 15 +- src/pyvesync/vesyncbasedevice.py | 59 ++++--- src/pyvesync/vesyncbulb.py | 261 +++++++++++++------------------ src/pyvesync/vesyncfan.py | 9 -- src/pyvesync/vesynckitchen.py | 2 +- 9 files changed, 238 insertions(+), 268 deletions(-) diff --git a/.pylintrc b/.pylintrc index 8794c95..a9c7d99 100644 --- a/.pylintrc +++ b/.pylintrc @@ -32,6 +32,7 @@ disable= format, abstract-method, arguments-differ, + broad-exception-caught, # FIXME cyclic-import, duplicate-code, global-statement, @@ -45,15 +46,15 @@ disable= 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, # FIXME wildcard-import, unused-wildcard-import, unnecessary-pass, unused-argument, - useless-super-delegation + useless-super-delegation, [REPORTS] #reports=no diff --git a/ruff.toml b/ruff.toml index 8248d67..6fde503 100644 --- a/ruff.toml +++ b/ruff.toml @@ -34,11 +34,27 @@ indent-width = 4 [lint] select = ["ALL"] -ignore = [] +ignore = ["T201", # PRINT statement - IGNORE + "COM812", # Missing trailing comma - IGNORE + "TRY003", # Avoid specifying long messages outside the exception class - TODO: Add exception classes + "TRY301", # raise within try - TODO: Fix this + "BLE001", # Broad Exceptions are not allowed - TODO: Fix this + "EM102", # Exception must not use an f-string literal, assign to variable first - TODO: add exception classes + "I001", # Import sort - TODO: Fix this + "EM101", # Exception must not use a string literal, assign to variable first - TODO: add exception classes + "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 + ] +# Todo: Add exception classes EM102, BLE001, EM101 +# Todo: Fix import sorting issue I001 unfixable = ["B"] [lint.per-file-ignores] "vesync.py" = ["F403"] +"vesyncbulb.py" = ["PLR2004"] [lint.pep8-naming] extend-ignore-names = ["displayJSON"] @@ -48,6 +64,7 @@ convention = "google" [lint.pylint] max-public-methods = 30 +max-args = 6 [format] quote-style = "preserve" diff --git a/setup.cfg b/setup.cfg index 4978a4a..ba5630f 100755 --- a/setup.cfg +++ b/setup.cfg @@ -14,6 +14,7 @@ log_cli = True [flake8] max-line-length = 90 +extend-ignore = D102 [pycodestyle] max-line-length = 90 diff --git a/src/pyvesync/helpers.py b/src/pyvesync/helpers.py index 29d54ae..e405c91 100644 --- a/src/pyvesync/helpers.py +++ b/src/pyvesync/helpers.py @@ -6,7 +6,7 @@ import json import colorsys from dataclasses import dataclass, field, InitVar -from typing import Any, NamedTuple, Optional, Union, TYPE_CHECKING +from typing import Any, NamedTuple, Union, TYPE_CHECKING import re import requests @@ -36,7 +36,7 @@ BYPASS_HEADER_UA = 'okhttp/3.12.1' -NUMERIC = Optional[Union[int, float, str]] +NUMERIC = Union[int, float, str, None] REQUEST_T = dict[str, Any] @@ -95,7 +95,7 @@ def req_header_bypass() -> dict[str, str]: } @staticmethod - def req_body_base(manager) -> dict[str, str]: + def req_body_base(manager: VeSync) -> dict[str, str]: """Return universal keys for body of api requests. Args: @@ -114,7 +114,7 @@ def req_body_base(manager) -> dict[str, str]: return {'timeZone': manager.time_zone, 'acceptLanguage': 'en'} @staticmethod - def req_body_auth(manager) -> REQUEST_T: + def req_body_auth(manager: VeSync) -> REQUEST_T: """Keys for authenticating api requests. Args: @@ -156,7 +156,7 @@ def req_body_details() -> REQUEST_T: } @classmethod - def req_body(cls, manager: VeSync, type_: str) -> REQUEST_T: + def req_body(cls, manager: VeSync, type_: str) -> REQUEST_T: # noqa: C901 """Builder for body of api requests. Args: @@ -234,17 +234,15 @@ def req_body(cls, manager: VeSync, type_: str) -> REQUEST_T: return body @staticmethod - def calculate_hex(hex_string) -> float: + def calculate_hex(hex_string: str) -> float: """Credit for conversion to itsnotlupus/vesync_wsproxy.""" hex_conv = hex_string.split(':') - converted_hex = (int(hex_conv[0], 16) + int(hex_conv[1], 16)) / 8192 - - return converted_hex + return (int(hex_conv[0], 16) + int(hex_conv[1], 16)) / 8192 @staticmethod - def hash_password(string) -> str: + def hash_password(string: str) -> str: """Encode password.""" - return hashlib.md5(string.encode('utf-8')).hexdigest() + return hashlib.md5(string.encode('utf-8')).hexdigest() # noqa: S324 shouldredact = True @@ -265,10 +263,12 @@ def redactor(cls, stringvalue: str) -> str: - cid Args: - stringvalue (str): The input string potentially containing sensitive information. + stringvalue (str): The input string potentially containing + sensitive information. Returns: - str: The redacted string with sensitive information replaced by '##_REDACTED_##'. + str: The redacted string with sensitive information replaced + by '##_REDACTED_##'. """ if cls.shouldredact: stringvalue = re.sub( @@ -302,14 +302,14 @@ def nested_code_check(response: dict) -> bool: if isinstance(response, dict): for key, value in response.items(): if (key == 'code' and value != 0) or \ - (isinstance(value, dict) and \ + (isinstance(value, dict) and not Helpers.nested_code_check(value)): return False return True @staticmethod - def call_api(api: str, method: str, json_object: Optional[dict] = None, - headers: Optional[dict] = None) -> tuple: + def call_api(api: str, method: str, json_object: dict | None = None, + headers: dict | None = None) -> tuple: """Make API calls by passing endpoint, header and body. api argument is appended to https://smartapi.vesync.com url @@ -353,7 +353,7 @@ def call_api(api: str, method: str, json_object: Optional[dict] = None, raise NameError(f'Invalid method {method}') except requests.exceptions.RequestException as e: logger.debug(e) - except Exception as e: # pylint: disable=broad-except + except Exception as e: logger.debug(e) else: if r.status_code == 200: @@ -372,9 +372,7 @@ def code_check(r: dict) -> bool: if r is None: logger.error('No response from API') return False - if isinstance(r, dict) and r.get('code') == 0: - return True - return False + return (isinstance(r, dict) and r.get('code') == 0) @staticmethod def build_details_dict(r: dict) -> dict: @@ -402,9 +400,9 @@ def build_details_dict(r: dict) -> dict: return { 'active_time': r.get('activeTime', 0), 'energy': r.get('energy', 0), - 'night_light_status': r.get('nightLightStatus', None), - 'night_light_brightness': r.get('nightLightBrightness', None), - 'night_light_automode': r.get('nightLightAutomode', None), + 'night_light_status': r.get('nightLightStatus'), + 'night_light_brightness': r.get('nightLightBrightness'), + 'night_light_automode': r.get('nightLightAutomode'), 'power': r.get('power', 0), 'voltage': r.get('voltage', 0), } @@ -475,7 +473,7 @@ def build_config_dict(r: dict) -> dict: } @classmethod - def bypass_body_v2(cls, manager) -> dict: + def bypass_body_v2(cls, manager: VeSync) -> dict: """Build body dict for second version of bypass api calls. Args: @@ -501,7 +499,7 @@ def bypass_body_v2(cls, manager) -> dict: } """ - bdy: dict[str, Union[str, bool]] = {} + bdy: dict[str, str | bool] = {} bdy.update( **cls.req_body(manager, "bypass") ) @@ -557,9 +555,12 @@ class HSV(NamedTuple): 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]. + 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 @@ -575,6 +576,7 @@ class RGB(NamedTuple): green (float): The green component of the RGB color. blue (float): The blue component of the RGB color. """ + red: float green: float blue: float @@ -582,17 +584,17 @@ class RGB(NamedTuple): @dataclass class Color: - """ - Dataclass for color values. + """Dataclass for color values. For HSV, pass hue as value in degrees 0-360, saturation and value as values between 0 and 100. For RGB, pass red, green and blue as values between 0 and 255. This - dataclass provides validation and conversion methods for both HSV and RGB color spaces. + dataclass provides validation and conversion methods for both HSV and RGB color spaces Notes: - To instantiate pass kw arguments for colors with *either* **hue, saturation and value** *or* - **red, green and blue**. RGB will take precedence if both are provided. Once instantiated, - the named tuples `hsv` and `rgb` will be available as attributes. + To instantiate pass kw arguments for colors with *either* **hue, saturation and + value** *or* **red, green and blue**. RGB will take precedence if both are + provided. Once instantiated, the named tuples `hsv` and `rgb` will be + available as attributes. Args: red (int): Red value of RGB color, 0-255 @@ -603,8 +605,10 @@ class Color: value (int): Value (brightness) value of HSV color, 0-100 Attributes: - hsv (namedtuple): hue (0-360), saturation (0-100), value (0-100) see [`HSV dataclass`][pyvesync.helpers.HSV] - rgb (namedtuple): red (0-255), green (0-255), blue (0-255) see [`RGB dataclass`][pyvesync.helpers.RGB] + hsv (namedtuple): hue (0-360), saturation (0-100), value (0-100) + see [`HSV dataclass`][pyvesync.helpers.HSV] + rgb (namedtuple): red (0-255), green (0-255), blue (0-255) + see [`RGB dataclass`][pyvesync.helpers.RGB] """ red: InitVar[NUMERIC] = field(default=None, repr=False, compare=False) @@ -617,19 +621,25 @@ class Color: hsv: HSV = field(init=False) rgb: RGB = field(init=False) - def __post_init__(self, red, green, blue, hue, saturation, value): + def __post_init__(self, + red: NUMERIC, + green: NUMERIC, + blue: NUMERIC, + hue: NUMERIC, + saturation: NUMERIC, + value: NUMERIC) -> None: """Check HSV or RGB Values and create named tuples.""" - if any(x is not None for x in [hue, saturation, value]): - self.hsv = HSV(*self.valid_hsv(hue, saturation, value)) - self.rgb = self.hsv_to_rgb(hue, saturation, value) - elif any(x is not None for x in [red, green, blue]): - self.rgb = RGB(*self.valid_rgb(red, green, blue)) - self.hsv = self.rgb_to_hsv(red, green, blue) + if None not in [hue, saturation, value]: + self.hsv = HSV(*self.valid_hsv(hue, saturation, value)) # type: ignore[arg-type] # noqa + self.rgb = self.hsv_to_rgb(hue, saturation, value) # type: ignore[arg-type] # noqa + elif None not in [red, green, blue]: + self.rgb = RGB(*self.valid_rgb(red, green, blue)) # type: ignore[arg-type] + self.hsv = self.rgb_to_hsv(red, green, blue) # type: ignore[arg-type] else: logger.error('No color values provided') @staticmethod - def _min_max(value: Union[int, float, str], min_val: float, + def _min_max(value: float | str, min_val: float, max_val: float, default: float) -> float: """Check if value is within min and max values.""" try: @@ -639,9 +649,9 @@ def _min_max(value: Union[int, float, str], min_val: float, return val @classmethod - def valid_hsv(cls, h: Union[int, float, str], - s: Union[int, float, str], - v: Union[int, float, str]) -> tuple: + def valid_hsv(cls, h: float | str, + s: float | str, + v: float | str) -> tuple: """Check if HSV values are valid.""" valid_hue = float(cls._min_max(h, 0, 360, 360)) valid_saturation = float(cls._min_max(s, 0, 100, 100)) @@ -662,7 +672,7 @@ def valid_rgb(cls, r: float, g: float, b: float) -> list: return rgb @staticmethod - def hsv_to_rgb(hue, saturation, value) -> RGB: + def hsv_to_rgb(hue: float, saturation: float, value: float) -> RGB: """Convert HSV to RGB.""" return RGB( *tuple(round(i * 255, 0) for i in colorsys.hsv_to_rgb( @@ -673,7 +683,7 @@ def hsv_to_rgb(hue, saturation, value) -> RGB: ) @staticmethod - def rgb_to_hsv(red, green, blue) -> HSV: + def rgb_to_hsv(red: float, green: float, blue: float) -> HSV: """Convert RGB to HSV.""" hsv_tuple = colorsys.rgb_to_hsv( red / 255, @@ -691,8 +701,7 @@ def rgb_to_hsv(red, green, blue) -> HSV: @dataclass class Timer: - """ - Dataclass to hold state of timers. + """Dataclass to hold state of timers. Note: This should be used by VeSync device instances to manage internal status, @@ -717,12 +726,12 @@ class Timer: timer_duration: int action: str id: int = 1 - remaining: InitVar[Optional[int]] = None + remaining: InitVar[int | None] = None _status: str = 'active' _remain: int = 0 - update_time: Optional[int] = int(time.time()) + update_time: int | None = int(time.time()) - def __post_init__(self, remaining) -> None: + def __post_init__(self, remaining: int | None) -> None: """Set remaining time if provided.""" if remaining is not None: self._remain = remaining @@ -738,10 +747,12 @@ def status(self) -> str: def status(self, status: str) -> None: """Set status of timer.""" if status not in ['active', 'paused', 'done']: - raise ValueError(f'Invalid status {status}') + logger.error('Invalid status %s', status) + raise ValueError self._internal_update() if status == 'done' or self._status == 'done': - return self.end() + self.end() + return if self.status == 'paused' and status == 'active': self.update_time = int(time.time()) if self.status == 'active' and status == 'paused': @@ -765,7 +776,8 @@ def time_remaining(self) -> int: def time_remaining(self, remaining: int) -> None: """Set time remaining in seconds.""" if remaining <= 0: - return self.end() + self.end() + return self._internal_update() if self._status == 'done': self._remain = 0 @@ -789,9 +801,7 @@ def _internal_update(self) -> None: @property def running(self) -> bool: """Check if timer is active.""" - if self.time_remaining > 0 and self.status == 'active': - return True - return False + return (self.time_remaining > 0 and self.status == 'active') @property def paused(self) -> bool: @@ -816,8 +826,8 @@ def start(self) -> None: self.update_time = int(time.time()) self.status = 'active' - def update(self, *, time_remaining: Optional[int] = None, - status: Optional[str] = None) -> None: + def update(self, *, time_remaining: int | None = None, + status: str | None = None) -> None: """Update timer. Accepts only KW args @@ -827,9 +837,6 @@ def update(self, *, time_remaining: Optional[int] = None, Time remaining on timer in seconds status : str Status of timer, can be active, paused, or done - - Returns: - None """ if time_remaining is not None: self.time_remaining = time_remaining diff --git a/src/pyvesync/vesync.py b/src/pyvesync/vesync.py index ca8f4de..39ca7a4 100644 --- a/src/pyvesync/vesync.py +++ b/src/pyvesync/vesync.py @@ -344,8 +344,10 @@ def process_devices(self, dev_list: list) -> bool: return True def get_devices(self) -> bool: - """Return tuple listing outlets, switches, and fans of devices. This is an internal method - called by `update()`""" + """Return tuple listing outlets, switches, and fans of devices. + + This is an internal method called by `update()` + """ if not self.enabled: return False @@ -408,12 +410,10 @@ def login(self) -> bool: def device_time_check(self) -> bool: """Test if update interval has been exceeded.""" - if ( + return ( self.last_update_ts is None or (time.time() - self.last_update_ts) > self.update_interval - ): - return True - return False + ) def update(self) -> None: """Fetch updated information about devices. @@ -421,9 +421,6 @@ def update(self) -> None: Pulls devices list from VeSync and instantiates any new devices. Devices are stored in the instance attributes `outlets`, `switches`, `fans`, and `bulbs`. The `_device_list` attribute is a dictionary of these attributes. - - Returns: - None """ if self.device_time_check(): diff --git a/src/pyvesync/vesyncbasedevice.py b/src/pyvesync/vesyncbasedevice.py index b87344a..a2942c8 100644 --- a/src/pyvesync/vesyncbasedevice.py +++ b/src/pyvesync/vesyncbasedevice.py @@ -2,8 +2,8 @@ from __future__ import annotations import logging import json -from typing import Optional, Union, TYPE_CHECKING -from pyvesync.helpers import Helpers as helper +from typing import TYPE_CHECKING +from pyvesync.helpers import Helpers as helper # noqa: N813 logger = logging.getLogger(__name__) if TYPE_CHECKING: @@ -51,24 +51,24 @@ def __init__(self, details: dict, manager: VeSync) -> None: """Initialize VeSync device base class.""" self.manager = manager if 'cid' in details and details['cid'] is not None: - self.device_name: str = details.get('deviceName', None) - self.device_image: Optional[str] = details.get('deviceImg', None) - self.cid: str = details.get('cid', None) - self.connection_status: str = details.get('connectionStatus', None) - self.connection_type: Optional[str] = details.get( - 'connectionType', None) - self.device_type: str = details.get('deviceType', None) - self.type: str = details.get('type', None) - self.uuid: Optional[str] = details.get('uuid', None) - self.config_module: str = details.get( - 'configModule', None) - self.mac_id: Optional[str] = details.get('macID', None) - self.mode: Optional[str] = details.get('mode', None) - self.speed: Union[str, int, None] = details.get('speed', None) - self.extension = details.get('extension', None) + self.device_name: str = details['deviceName'] + self.device_image: str | None = details.get('deviceImg') + self.cid: str = details['cid'] + self.connection_status: str = details['connectionStatus'] + self.connection_type: str | None = details.get( + 'connectionType') + self.device_type: str = details['deviceType'] + self.type: str | None = details.get('type') + self.uuid: str | None = details.get('uuid') + self.config_module: str = details['configModule'] + self.mac_id: str | None = details.get('macID') + self.mode: str | None = details.get('mode') + self.speed: int | None = details.get('speed') if details.get( + 'speed') != '' else None + self.extension = details.get('extension') self.current_firm_version = details.get( - 'currentFirmVersion', None) - self.device_region: Optional[str] = details.get('deviceRegion', None) + 'currentFirmVersion') + self.device_region: str | None = details.get('deviceRegion') self.pid = None self.sub_device_no = details.get('subDeviceNo', 0) self.config: dict = {} @@ -77,32 +77,34 @@ def __init__(self, details: dict, manager: VeSync) -> None: self.speed = ext.get('fanSpeedLevel') self.mode = ext.get('mode') if self.connection_status != 'online': - self.device_status = 'off' + self.device_status: str | None = 'off' else: - self.device_status = details.get('deviceStatus', None) + self.device_status = details.get('deviceStatus') else: logger.error('No cid found for %s', self.__class__.__name__) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: """Use device CID and subdevice 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): + def __hash__(self) -> int: """Use CID and sub-device number to make device hash.""" if isinstance(self.sub_device_no, int) and self.sub_device_no > 0: return hash(self.cid + str(self.sub_device_no)) return hash(self.cid) - def __str__(self): + def __str__(self) -> str: """Use device info for string represtation of class.""" return f'Device Name: {self.device_name}, \ Device Type: {self.device_type},\ SubDevice No.: {self.sub_device_no},\ Status: {self.device_status}' - def __repr__(self): + def __repr__(self) -> str: """Representation of device details.""" return f'DevClass: {self.__class__.__name__},\ Name:{self.device_name}, Device No: {self.sub_device_no},\ @@ -111,9 +113,7 @@ def __repr__(self): @property def is_on(self) -> bool: """Return true if device is on.""" - if self.device_status == 'on': - return True - return False + return (self.device_status == 'on') @property def firmware_update(self) -> bool: @@ -144,9 +144,6 @@ def get_pid(self) -> None: def display(self) -> None: """Print formatted device info to stdout. - Returns: - None - Example: ``` Device Name:..................Living Room Lamp diff --git a/src/pyvesync/vesyncbulb.py b/src/pyvesync/vesyncbulb.py index cc6ec24..8cf7f42 100644 --- a/src/pyvesync/vesyncbulb.py +++ b/src/pyvesync/vesyncbulb.py @@ -10,11 +10,13 @@ Attributes: feature_dict (dict): Dictionary of bulb models and their supported features. Defines the class to use for each bulb model and the list of features - bulb_modules (dict): Dictionary of bulb models as keys and their associated classes as string values. + bulb_modules (dict): Dictionary of bulb models as keys and their associated classes + as string values. Note: - The bulb module is built from the `feature_dict` dictionary and used by the `vesync.object_factory` and tests - to determine the class to instantiate for each bulb model. + The bulb module is built from the `feature_dict` dictionary and used by the + `vesync.object_factory` and tests to determine the class to instantiate for + each bulb model. Examples: The following example shows the structure of the `feature_dict` dictionary: @@ -33,7 +35,7 @@ from __future__ import annotations import logging import json -from typing import Union, Dict, Optional, NamedTuple, TYPE_CHECKING +from typing import Union, Optional, NamedTuple, TYPE_CHECKING from abc import ABCMeta, abstractmethod from pyvesync.helpers import Helpers as helpers, Color from pyvesync.vesyncbasedevice import VeSyncBaseDevice @@ -81,15 +83,15 @@ def pct_to_kelvin(pct: float, max_k: int = 6500, min_k: int = 2700) -> float: """Convert percent to kelvin.""" - kelvin = ((max_k - min_k) * pct / 100) + min_k - return kelvin + return ((max_k - min_k) * pct / 100) + min_k class VeSyncBulb(VeSyncBaseDevice): """Base class for VeSync Bulbs. Abstract base class to provide methods for controlling and - getting details of VeSync bulbs. Inherits from [`VeSyncBaseDevice`][pyvesync.vesyncbasedevice.VeSyncBaseDevice]. + getting details of VeSync bulbs. Inherits from + [`VeSyncBaseDevice`][pyvesync.vesyncbasedevice.VeSyncBaseDevice]. Attributes: brightness (int): Brightness of bulb (0-100). @@ -98,36 +100,33 @@ class VeSyncBulb(VeSyncBaseDevice): color_hue (float): Color hue of bulb (0-360). color_saturation (float): Color saturation of bulb in percent (0-100). color_value (float): Color value of bulb in percent (0-100). - color (Color): Color of bulb in the form of a dataclass with two named tuple attributes - `hsv` & `rgb`. See [pyvesync.helpers.Color][]. + color (Color): Color of bulb in the form of a dataclass with two namedtuple + attributes - `hsv` & `rgb`. See [pyvesync.helpers.Color][]. """ __metaclass__ = ABCMeta - def __init__(self, details: Dict[str, Union[str, list]], + def __init__(self, details: dict[str, str | list], manager: VeSync) -> None: """Initialize VeSync smart bulb base class.""" super().__init__(details, manager) - self._brightness = int(0) - self._color_temp = int(0) + self._brightness = 0 + self._color_temp = 0 self._color_value = float(0) self._color_hue = float(0) self._color_saturation = float(0) self._color_mode: str = '' # possible: white, color, hsv - self._color: Optional[Color] = None - self.features: Optional[list] = feature_dict.get( + self._color: Color | None = None + self.features: list | None = feature_dict.get( self.device_type, {}).get('features') if self.features is None: logger.error("No configuration set for - %s", self.device_name) - raise KeyError(f"No configuration set for {self.device_name}") + raise KeyError self._rgb_values = { 'red': 0, 'green': 0, 'blue': 0 } - # self.get_config() - # self.current_firm_version = self.config.get('currentFirmVersion', None) - # self.latest_firm_version = self.config.get('latestFirmVersion', None) - # self.firmware_url = self.config.get('firmwareUrl', None) @property def brightness(self) -> int: @@ -217,7 +216,9 @@ def color_value(self) -> float: return 0 @property - def color(self) -> Optional[Color]: + # pylint: disable-next=differing-param-doc # DOCUMENTATION FOR SETTER + def color(self) -> Color | None: + # pylint: disable=differing-type-doc """Set color property based on rgb or hsv values. Pass either red, green, blue or hue, saturation, value. @@ -238,18 +239,18 @@ def color(self) -> Optional[Color]: return None @color.setter - def color(self, red: Optional[float] = None, - green: Optional[float] = None, - blue: Optional[float] = None, - hue: Optional[float] = None, - saturation: Optional[float] = None, - value: Optional[float] = None) -> None: + def color(self, red: float | None = None, + green: float | None = None, + blue: float | None = None, + hue: float | None = None, + saturation: float | None = None, + value: float | None = None) -> None: """Set color property based on rgb or hsv values.""" self._color = Color(red=red, green=green, blue=blue, hue=hue, saturation=saturation, value=value) @property - def color_hsv(self) -> Optional[NamedTuple]: + def color_hsv(self) -> NamedTuple | None: """Return color of bulb as [hsv named tuple][pyvesync.helpers.HSV]. Notes: @@ -260,7 +261,7 @@ def color_hsv(self) -> Optional[NamedTuple]: return None @property - def color_rgb(self) -> Optional[NamedTuple]: + def color_rgb(self) -> NamedTuple | None: """Return color of bulb as [rgb named tuple][pyvesync.helpers.RGB]. Notes: @@ -271,7 +272,7 @@ def color_rgb(self) -> Optional[NamedTuple]: return None @property - def color_mode(self) -> Optional[str]: + def color_mode(self) -> str | None: """Return color mode of bulb. Possible values are none, hsv or rgb. Notes: @@ -285,9 +286,7 @@ def color_mode(self) -> Optional[str]: @property def dimmable_feature(self) -> bool: """Return true if dimmable bulb.""" - if self.features is not None and 'dimmable' in self.features: - return True - return False + return (self.features is not None and 'dimmable' in self.features) @property def color_temp_feature(self) -> bool: @@ -296,9 +295,7 @@ def color_temp_feature(self) -> bool: Returns: bool: True if the device supports changing color temperature. """ - if self.features is not None and 'color_temp' in self.features: - return True - return False + return (self.features is not None and 'color_temp' in self.features) @property def rgb_shift_feature(self) -> bool: @@ -307,9 +304,7 @@ def rgb_shift_feature(self) -> bool: Returns: bool: True if the device supports changing color. """ - if self.features is not None and 'rgb_shift' in self.features: - return True - return False + return (self.features is not None and 'rgb_shift' in self.features) def _validate_rgb(self, red: NUMERIC_T = None, green: NUMERIC_T = None, @@ -325,71 +320,62 @@ def _validate_rgb(self, red: NUMERIC_T = None, green=rgb_dict['green'], blue=rgb_dict['blue']) - def _validate_hsv(self, hue: Optional[NUMERIC_T] = None, - saturation: Optional[NUMERIC_T] = None, - value: Optional[NUMERIC_T] = None) -> Color: + def _validate_hsv(self, hue: NUMERIC_T = None, + saturation: NUMERIC_T = None, + value: NUMERIC_T = None) -> Color: """Validate HSV Arguments.""" hsv_dict = {'hue': hue, 'saturation': saturation, 'value': value} for clr, val in hsv_dict.items(): - if val is None: - if self._color is not None: - hsv_dict[clr] = getattr(self._color.hsv, clr) + if val is None and self._color is not None: + hsv_dict[clr] = getattr(self._color.hsv, clr) if hue is not None: valid_hue = self._validate_any(hue, 1, 360, 360) + elif self._color is not None: + valid_hue = self._color.hsv.hue else: - if self._color is not None: - valid_hue = self._color.hsv.hue - else: - logger.debug("No current hue value, setting to 0") - valid_hue = 360 + logger.debug("No current hue value, setting to 0") + valid_hue = 360 hsv_dict['hue'] = valid_hue for itm, val in {'saturation': saturation, 'value': value}.items(): if val is not None: valid_item = self._validate_any(val, 1, 100, 100) + elif self.color is not None: + valid_item = getattr(self.color.hsv, itm) else: - if self.color is not None: - valid_item = getattr(self.color.hsv, itm) - else: - logger.debug("No current %s value, setting to 0", itm) - valid_item = 100 + logger.debug("No current %s value, setting to 0", itm) + valid_item = 100 hsv_dict[itm] = valid_item return Color(hue=hsv_dict['hue'], saturation=hsv_dict['saturation'], value=hsv_dict['value']) - def _validate_brightness(self, brightness: Union[int, float, str], + def _validate_brightness(self, brightness: float | str, start: int = 0, stop: int = 100) -> int: """Validate brightness value.""" try: brightness_update: int = max(start, (min(stop, int( round(float(brightness), 2))))) except (ValueError, TypeError): - if self._brightness is not None: - brightness_update = self.brightness - else: - brightness_update = 100 + brightness_update = self.brightness if self.brightness is not None else 100 return brightness_update - def _validate_color_temp(self, temp: int, start: int = 0, stop: int = 100): + def _validate_color_temp(self, temp: int, start: int = 0, stop: int = 100) -> int: """Validate color temperature.""" try: temp_update = max(start, (min(stop, int( round(float(temp), 0))))) except (ValueError, TypeError): - if self._color_temp is not None: - temp_update = self._color_temp - else: - temp_update = 100 + temp_update = self._color_temp if self._color_temp is not None else 100 return temp_update @staticmethod - def _validate_any(value: Union[int, float, str], - start: Union[int, float] = 0, - stop: Union[int, float] = 100, - default: Union[int, float] = 100) -> float: + def _validate_any(value: NUMERIC_T, + start: NUMERIC_T = 0, + stop: NUMERIC_T = 100, + default: float = 100) -> float: """Validate any value.""" try: - value_update = max(float(start), (min(float(stop), round(float(value), 2)))) + value_update = max(float(start), (min(float(stop), round(float(value), 2)))) # type: ignore[arg-type] # noqa except (ValueError, TypeError): value_update = default return value_update @@ -455,13 +441,11 @@ def get_config(self) -> None: } ``` """ - pass def set_hsv(self, hue: NUMERIC_T, saturation: NUMERIC_T, - value: NUMERIC_T - ) -> Optional[bool]: + value: NUMERIC_T) -> bool | None: """Set HSV if supported by bulb. Args: @@ -475,12 +459,11 @@ def set_hsv(self, if self.rgb_shift_feature is False: logger.debug("HSV not supported by bulb") return False - return True + return bool(hue and saturation and value) def set_rgb(self, red: NUMERIC_T = None, green: NUMERIC_T = None, - blue: NUMERIC_T = None - ) -> bool: + blue: NUMERIC_T = None) -> bool: """Set RGB if supported by bulb. Args: @@ -494,7 +477,7 @@ def set_rgb(self, red: NUMERIC_T = None, if self.rgb_shift_feature is False: logger.debug("RGB not supported by bulb") return False - return True + return bool(red and green and blue) def turn_on(self) -> bool: """Turn on vesync bulbs. @@ -530,9 +513,6 @@ def update(self) -> None: Calls `get_details()` method to retrieve status from API and update the bulb attributes. `get_details()` is overriden by subclasses to hit the respective API endpoints. - - Returns: - None """ self.get_details() @@ -581,14 +561,14 @@ def displayJSON(self) -> str: return json.dumps(sup_val, indent=4) @property - def color_value_rgb(self) -> Optional[NamedTuple]: + def color_value_rgb(self) -> NamedTuple | None: """Legacy Method .... Depreciated.""" if self._color is not None: return self._color.rgb return None @property - def color_value_hsv(self) -> Optional[NamedTuple]: + def color_value_hsv(self) -> NamedTuple | None: """Legacy Method .... Depreciated.""" if self._color is not None: return self._color.hsv @@ -628,7 +608,7 @@ class VeSyncBulbESL100MC(VeSyncBulb): ``` """ - def __init__(self, details: Dict[str, Union[str, list]], manager: VeSync) -> None: + def __init__(self, details: dict[str, str | list], manager: VeSync) -> None: """Instantiate ESL100MC Multicolor Bulb. Args: @@ -668,14 +648,13 @@ def get_details(self) -> None: self._interpret_apicall_result(inner_result) return - def _interpret_apicall_result(self, response: dict): + def _interpret_apicall_result(self, response: dict) -> None: """Build detail dictionary from response.""" self._brightness = response.get('brightness', 0) self._color_mode = response.get('colorMode', '') self._color = Color(red=response.get('red', 0), green=response.get('green', 0), blue=response.get('blue', 0)) - return True def set_brightness(self, brightness: int) -> bool: """Set brightness of bulb. @@ -702,8 +681,7 @@ def set_rgb(self, red: NUMERIC_T = None, def set_hsv(self, hue: NUMERIC_T, saturation: NUMERIC_T, - value: NUMERIC_T - ) -> Optional[bool]: + value: NUMERIC_T) -> bool | None: rgb = Color(hue=hue, saturation=saturation, value=value).rgb return self.set_status(red=rgb.red, green=rgb.green, blue=rgb.blue) @@ -715,10 +693,10 @@ def enable_white_mode(self) -> bool: """ return self.set_status(brightness=100) - def set_status(self, brightness: Optional[NUMERIC_T] = None, - red: Optional[NUMERIC_T] = None, - green: Optional[NUMERIC_T] = None, - blue: Optional[NUMERIC_T] = None) -> bool: + def set_status(self, brightness: NUMERIC_T = None, + red: NUMERIC_T = None, + green: NUMERIC_T = None, + blue: NUMERIC_T = None) -> bool: """Set color of VeSync ESL100MC. Brightness or RGB values must be provided. If RGB values are provided, @@ -842,7 +820,7 @@ class VeSyncBulbESL100(VeSyncBulb): connection_status (str): Connection status of bulb (online/offline). """ - def __init__(self, details: dict, manager) -> None: + def __init__(self, details: dict, manager: VeSync) -> None: """Initialize Etekcity ESL100 Dimmable Bulb. Args: @@ -886,7 +864,7 @@ def get_config(self) -> None: else: logger.debug('Error getting %s config info', self.device_name) - def toggle(self, status) -> bool: + def toggle(self, status: str) -> bool: body = helpers.req_body(self.manager, 'devicestatus') body['uuid'] = self.uuid body['status'] = status @@ -941,7 +919,7 @@ def set_brightness(self, brightness: int) -> bool: class VeSyncBulbESL100CW(VeSyncBulb): """VeSync Tunable and Dimmable White Bulb.""" - def __init__(self, details, manager: VeSync) -> None: + def __init__(self, details: dict, manager: VeSync) -> None: """Initialize Etekcity Tunable white bulb.""" super().__init__(details, manager) @@ -975,7 +953,7 @@ def get_details(self) -> None: str(r.get('msg', '')), ) - def _interpret_apicall_result(self, response) -> None: + def _interpret_apicall_result(self, response: dict) -> None: self.connection_status = 'online' self.device_status = response.get('action', 'off') self._brightness = response.get('brightness', 0) @@ -997,7 +975,7 @@ def get_config(self) -> None: else: logger.debug('Error getting %s config info', self.device_name) - def toggle(self, status) -> bool: + def toggle(self, status: str) -> bool: if status not in ('on', 'off'): logger.debug('Invalid status %s', status) return False @@ -1028,7 +1006,7 @@ def set_brightness(self, brightness: int) -> bool: body = helpers.req_body(self.manager, 'bypass') body['cid'] = self.cid body['configModule'] = self.config_module - light_dict: Dict[str, NUMERIC_T] = { + light_dict: dict[str, NUMERIC_T] = { 'brightness': brightness_update} if self.device_status == 'off': light_dict['action'] = 'on' @@ -1094,7 +1072,7 @@ def set_color_temp(self, color_temp: int) -> bool: class VeSyncBulbValcenoA19MC(VeSyncBulb): """VeSync Multicolor Bulb.""" - def __init__(self, details: dict, manager) -> None: + def __init__(self, details: dict, manager: VeSync) -> None: """Initialize Multicolor bulb.""" super().__init__(details, manager) @@ -1169,7 +1147,7 @@ def get_config(self) -> None: logger.debug(' return code - %d with message %s', r.get('code'), r.get('msg')) - def __build_config_dict(self, conf_dict: Dict[str, str]) -> None: + def __build_config_dict(self, conf_dict: dict[str, str]) -> None: """Build configuration dict for Multicolor bulb.""" self.config['currentFirmVersion'] = ( conf_dict.get('currentFirmVersion', '')) @@ -1258,45 +1236,33 @@ def set_color_mode(self, color_mode: str) -> bool: def set_hsv(self, hue: NUMERIC_T = None, saturation: NUMERIC_T = None, value: NUMERIC_T = None) -> bool: - arg_dict = {"hue": hue, "saturation": saturation, "value": value} - if hue is not None: - hue_update: NUMERIC_T = self._validate_any(hue, 0, 360, 360) - else: - hue_update = "" - if saturation is not None: - sat_update: NUMERIC_T = self._validate_any(saturation, 0, 100, 100) - else: - sat_update = "" - if value is not None: - value_update: NUMERIC_T = self._validate_any(value, 0, 100, 100) - else: - value_update = "" arg_dict = { - "hue": hue_update, - "saturation": sat_update, - "brightness": value_update - } - # the api expects the hsv Value in the brightness parameter + "hue": self._validate_any(hue, 0, 360, 360) if hue is not None else "", + "saturation": self._validate_any( + saturation, 0, 100, 100) if saturation is not None else "", + "brightness": self._validate_any( + value, 0, 100, 100) if value is not None else "" + } + # the api expects the hsv Value in the brightness parameter if self._color is not None: current_dict = {"hue": self.color_hue, "saturation": self.color_saturation, "brightness": self.color_value} - same_colors = True - for key, val in arg_dict.items(): - if val != "": - if val != current_dict[key]: - same_colors = False + filtered_arg_dict = {k: v for k, v in arg_dict.items() if v != ""} + same_colors = all(current_dict.get(k) == v + for k, v in filtered_arg_dict.items()) if self.device_status == 'on' and same_colors: logger.debug("Device already in requested state") return True - for key, val in arg_dict.items(): - if key == 'hue' and isinstance(val, float): - arg_dict[key] = int(round(val*27.77778, 0)) - if key == "saturation" and isinstance(val, float): - arg_dict[key] = int(round(val*100, 0)) - if key == "brightness" and isinstance(val, float): - arg_dict[key] = int(round(val, 0)) + arg_dict = { + "hue": int(round(arg_dict["hue"]*27.77778, 0)) if isinstance( + arg_dict["hue"], float) else "", + "saturation": int(round(arg_dict["saturation"]*100, 0)) if isinstance( + arg_dict["saturation"], float) else "", + "brightness": int(round(arg_dict["brightness"], 0)) if isinstance( + arg_dict["brightness"], float) else "" + } arg_dict['colorMode'] = 'hsv' return self._set_status_api(arg_dict) @@ -1304,17 +1270,18 @@ def enable_white_mode(self) -> bool: """Enable white color mode.""" return self.set_status(color_mode='white') - def set_status(self, + def set_status(self, # noqa: C901 + *, brightness: NUMERIC_T = None, color_temp: NUMERIC_T = None, color_saturation: NUMERIC_T = None, color_hue: NUMERIC_T = None, - color_mode: Optional[str] = None, + color_mode: str | None = None, color_value: NUMERIC_T = None ) -> bool: """Set multicolor bulb parameters. - No arguments turns bulb on. + No arguments turns bulb on. **Kwargs only** Args: brightness (int, optional): brightness between 0 and 100 @@ -1339,8 +1306,7 @@ def set_status(self, # If any HSV color values are passed, # set HSV status & ignore other values # Set Color if hue, saturation or value is set - if color_hue is not None or color_value is not None or \ - color_saturation is not None: + if any(var is not None for var in [color_hue, color_saturation, color_value]): return self.set_hsv(color_hue, color_saturation, color_value) # initiate variables @@ -1357,38 +1323,31 @@ def set_status(self, force_list = ['colorTemp', 'saturation', 'hue', 'colorMode', 'value'] if brightness is not None: brightness_update = self._validate_brightness(brightness) - + if self.device_status == 'on' and brightness_update == self._brightness: + logger.debug('Brightness already set to %s', brightness) + return True if all(locals().get(k) is None for k in force_list): - - # Do nothing if brightness is passed and same as current - if self.device_status == 'on' and brightness_update == self._brightness: - logger.debug('Brightness already set to %s', brightness) - return True request_dict['force'] = 0 request_dict['brightness'] = int(brightness_update) else: brightness_update = None # Set White Temperature of Bulb in pct (1 - 100). - if color_temp is not None: + if color_temp is not None and \ + self._validate_any(color_temp, 0, 100, 100): valid_color_temp = self._validate_any(color_temp, 0, 100, 100) - if valid_color_temp is not None: - request_dict['colorTemp'] = int(valid_color_temp) - request_dict['colorMode'] = 'white' + request_dict['colorTemp'] = int(valid_color_temp) + request_dict['colorMode'] = 'white' - # """Set Color Mode of Bulb (white / hsv).""" + # Set Color Mode of Bulb (white / hsv). if color_mode is not None: - if not isinstance(color_mode, str): - logger.error('Error: color_mode should be a string value') - return False - color_mode = color_mode.lower() possible_modes = {'white': 'white', 'color': 'hsv', 'hsv': 'hsv'} - if color_mode not in possible_modes: - logger.error( - 'Color mode specified is not acceptable ' - '(Try: "white"/"color"/"hsv")') + if not isinstance(color_mode, str) or \ + color_mode.lower() not in possible_modes: + logger.error('Error: invalid color_mode value') return False + color_mode = color_mode.lower() request_dict['colorMode'] = possible_modes[color_mode] if self._set_status_api(request_dict) and \ brightness_update is not None: diff --git a/src/pyvesync/vesyncfan.py b/src/pyvesync/vesyncfan.py index c287a11..524b7a1 100644 --- a/src/pyvesync/vesyncfan.py +++ b/src/pyvesync/vesyncfan.py @@ -324,9 +324,6 @@ def build_purifier_dict(self, dev_dict: dict) -> None: Args: dev_dict (dict): Dictionary of device details from API - Returns: - None - Examples: >>> dev_dict = { ... 'enabled': True, @@ -382,9 +379,6 @@ def build_config_dict(self, conf_dict: Dict[str, str]) -> None: Args: conf_dict (dict): Dictionary of device configuration - - Returns: - None """ self.config['display'] = conf_dict.get('display', False) self.config['display_forever'] = conf_dict.get('display_forever', @@ -1027,9 +1021,6 @@ def display(self) -> None: Builds on the `display()` method from the `VeSyncBaseDevice` class. - Returns: - None - See Also: [pyvesync.VeSyncBaseDevice.display][`VeSyncBaseDevice.display`] """ diff --git a/src/pyvesync/vesynckitchen.py b/src/pyvesync/vesynckitchen.py index c9b2ffd..149c804 100644 --- a/src/pyvesync/vesynckitchen.py +++ b/src/pyvesync/vesynckitchen.py @@ -170,7 +170,7 @@ 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): + def status_request(self, json_cmd: dict) -> None: # pylint: disable=R1260 """Set status from jsonCmd of API call.""" self.last_timestamp = None if not isinstance(json_cmd, dict): From 62a8e7a04669317c2dac5266ce67243d96080f4b Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Sun, 20 Oct 2024 18:33:05 -0400 Subject: [PATCH 3/4] Version Bump --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 4236e05..ad3ad94 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup( name='pyvesync', - version='2.1.12', + version='2.1.13', description='pyvesync is a library to manage Etekcity\ Devices, Cosori Air Fryers and Levoit Air \ Purifiers run on the VeSync app.', From 371adb1aaf30e5c59ab116aebf0073247cc238db Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Sun, 20 Oct 2024 18:43:44 -0400 Subject: [PATCH 4/4] Update .pylintrc Add too-many-positional arguments exception. This error will be addressed in the next release. --- .pylintrc | 1 + 1 file changed, 1 insertion(+) diff --git a/.pylintrc b/.pylintrc index a9c7d99..1235c07 100644 --- a/.pylintrc +++ b/.pylintrc @@ -46,6 +46,7 @@ disable= 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,