Repository: PaulAnnekov/tuyaha Branch: master Commit: 8a487d704cec Files: 21 Total size: 37.6 KB Directory structure: gitextract__3_71an3/ ├── .drone.yml ├── .gitignore ├── .vscode/ │ └── settings.json ├── LICENSE ├── MANIFEST.in ├── README.md ├── setup.py ├── tools/ │ └── debug_discovery.py └── tuyaha/ ├── __init__.py ├── devices/ │ ├── __init__.py │ ├── base.py │ ├── climate.py │ ├── cover.py │ ├── factory.py │ ├── fan.py │ ├── light.py │ ├── lock.py │ ├── remote.py │ ├── scene.py │ └── switch.py └── tuyaapi.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .drone.yml ================================================ kind: pipeline name: default steps: - name: publish image: plugins/pypi settings: username: from_secret: pypi_username password: from_secret: pypi_password distributions: - sdist - bdist_wheel when: ref: - refs/tags/*.*.* ================================================ FILE: .gitignore ================================================ .idea ================================================ FILE: .vscode/settings.json ================================================ { "cSpell.words": [ "annekov", "pavlo", "tuya", "tuyacloudurl", "tuyaha", "tuyapy" ] } ================================================ FILE: LICENSE ================================================ Copyright for portions of project tuyaha are held by Tuya Inc., 2018 as part of project tuyapy. All other copyright for project tuyaha are held by Pavlo Annekov, 2019. MIT License Copyright (c) 2019 Pavlo Annekov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: MANIFEST.in ================================================ include LICENSE ================================================ FILE: README.md ================================================ # tuyaha ## This library was maintained exclusively for Home Assistant Tuya component. As HA 2021.10.0 switched to Tuya-supported [tuya-iot-py-sdk](https://pypi.org/project/tuya-iot-py-sdk/) library (finally 😮‍💨), there is no sense to continue supporting this library which uses deprecated API. Cloned from the abandoned package [tuyapy](https://pypi.org/project/tuyapy/) v0.1.3. This package implements a Tuya API endpoint that was specially designed for Home Assistant. This clone contains several critical fixes. Check commits. ## FAQ ### How to check whether the API this library using can control your device? - Copy [this script](https://github.com/PaulAnnekov/tuyaha/blob/master/tools/debug_discovery.py) to your PC with Python installed or to https://repl.it/ - Set/update config inside and run it - Check if your devices are listed - If they are and description matches real device (e.g. lamp is lamp, not switch) - device is supported - If they are not or description doesn't match real device - don't open an issue. Ask [Tuya support](mailto:support@tuya.com) to support your device in their `/homeassistant` API - Remove the updated script, so your credentials won't leak ### My device is not listed in Tuya API response or contains incomplete state, what should I do? Try new custom component from Tuya developers https://github.com/tuya/tuya-home-assistant/ or ask them to support your device. ================================================ FILE: setup.py ================================================ # coding=utf-8 import setuptools with open("README.md", "r") as fh: long_description = fh.read() setuptools.setup( name="tuyaha", version="0.0.11", author="Pavlo Annekov and original Tuya authors", author_email="paul.annekov@gmail.com", description="A Python library that implements a Tuya API endpoint that was specially designed for Home Assistant", long_description=long_description, long_description_content_type="text/markdown", packages=setuptools.find_packages(), url="https://github.com/PaulAnnekov/tuyaha", license="MIT", install_requires=["requests"], classifiers=( "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ), ) ================================================ FILE: tools/debug_discovery.py ================================================ # The script is intended to get a list of all devices available via Tuya Home Assistant API endpoint. import requests import pprint # CHANGE THIS - BEGGINING USERNAME = "" PASSWORD = "" REGION = "eu" # cn, eu, us COUNTRY_CODE = "1" # Your account country code, e.g., 1 for USA or 86 for China BIZ_TYPE = "smart_life" # tuya, smart_life, jinvoo_smart FROM = "tuya" # you likely don't need to touch this # CHANGE THIS - END # NO NEED TO CHANGE ANYTHING BELOW TUYACLOUDURL = "https://px1.tuya{}.com" pp = pprint.PrettyPrinter(indent=4) print("Getting credentials") auth_response = requests.post( (TUYACLOUDURL + "/homeassistant/auth.do").format(REGION), data={ "userName": USERNAME, "password": PASSWORD, "countryCode": COUNTRY_CODE, "bizType": BIZ_TYPE, "from": FROM, }, ) print("Got credentials") auth_response = auth_response.json() pp.pprint(auth_response) header = {"name": "Discovery", "namespace": "discovery", "payloadVersion": 1} payload = {"accessToken": auth_response["access_token"]} data = {"header": header, "payload": payload} print("Getting devices") discovery_response = requests.post( (TUYACLOUDURL + "/homeassistant/skill").format(REGION), json=data ) print("Got devices") discovery_response = discovery_response.json() pp.pprint(discovery_response) print("!!! NOW REMOVE THIS FILE, SO YOUR CREDENTIALS (username, password) WON'T LEAK !!!") ================================================ FILE: tuyaha/__init__.py ================================================ """Init file for test""" from .tuyaapi import TuyaApi ================================================ FILE: tuyaha/devices/__init__.py ================================================ ================================================ FILE: tuyaha/devices/base.py ================================================ import time from datetime import datetime class TuyaDevice: def __init__(self, data, api): self.api = api self.data = data.get("data") self.obj_id = data.get("id") self.obj_type = data.get("ha_type") self.obj_name = data.get("name") self.dev_type = data.get("dev_type") self.icon = data.get("icon") self._first_update = True self._last_update = datetime.min self._last_query = datetime.min def name(self): return self.obj_name def state(self): state = self.data.get("state") if state is None: return None elif isinstance(state, str): if state == "true": return True return False else: return bool(state) def device_type(self): return self.dev_type def object_id(self): return self.obj_id def object_type(self): return self.obj_type def available(self): return self.data.get("online") def iconurl(self): return self.icon def _update_data(self, key, value, force_val=False): if self.data: # device properties not provided by Tuya API are saved in the # device cache only if force_val=True. This is used to force # in cache missing API values (e.g color mode for light) if not force_val and self.data.get(key) is None: return self.data[key] = value self.api.update_device_data(self.obj_id, self.data) def _control_device(self, action, param=None): success, response = self.api.device_control(self.obj_id, action, param) if not success: self._update_data("online", False) else: self._last_update = datetime.now() return success # Update device cache using discovery or query command # Due to the limitation of both command it is possible # to choose which one to use. Because discovery return data # for all devices, is preferred method with multiple device # Query can be called with higher frequency but return # values for a single device def _update(self, use_discovery): # Avoid get cache value after control. difference = (datetime.now() - self._last_update).total_seconds() wait_delay = difference < 0.5 data = None if use_discovery or self._first_update: if wait_delay: time.sleep(0.5) # workaround for https://github.com/PaulAnnekov/tuyaha/issues/3 self._first_update = False devices = self.api.discovery() if not devices: return for device in devices: if device["id"] == self.obj_id: data = device["data"] break else: # query can be called once every 60 seconds difference = (datetime.now() - self._last_query).total_seconds() if difference < self.api.query_interval: return if difference == self.api.query_interval: wait_delay = True if wait_delay: time.sleep(0.5) try: success, response = self.api.device_control( self.obj_id, "QueryDevice", namespace="query" ) finally: self._last_query = datetime.now() if success: data = response["payload"]["data"] if data: if not self.data: self.data = data else: self.data.update(data) return True return def __repr__(self): module = self.__class__.__module__ if module is None or module == str.__class__.__module__: module = "" else: module += "." return '<{module}{clazz}: "{name}" ({obj_id})>'.format( module=module, clazz=self.__class__.__name__, name=self.obj_name, obj_id=self.obj_id ) def update(self, use_discovery=True): return self._update(use_discovery) ================================================ FILE: tuyaha/devices/climate.py ================================================ from tuyaha.devices.base import TuyaDevice UNIT_CELSIUS = "CELSIUS" UNIT_FAHRENHEIT = "FAHRENHEIT" STEP_WHOLE = 1 STEP_HALVES = 0.5 STEP_TENTHS = 0.1 TEMP_STEPS = { STEP_WHOLE: "Whole", STEP_HALVES: "Halves", STEP_TENTHS: "Tenths", } class TuyaClimate(TuyaDevice): def __init__(self, data, api): super().__init__(data, api) self._unit = None self._divider = 0 self._divider_set = False self._ct_divider = 0 # this function return the temperature value # divided by the _divider attribute previous set # this is required because in same case API provide # a temperature value that must be divided to provide decimal # in other case just return the right temperature values def _set_decimal(self, val, divider=0): if val is None: return None if divider == 0: divider = self._divider if divider == 0: if val > 500 or val < -100: # in this case we suppose that returned value # support decimal and must be divided by 100 divider = 100 self._divider = divider else: divider = 1 return round(float(val / divider), 2) # when unit is not provided by the API or the API return # incorrect value, it can be forced by this method # the _unit attribute is used by the temperature_unit() method def set_unit(self, unit): """Set temperature unit (CELSIUS or FAHRENHEIT)""" if unit != UNIT_CELSIUS and unit != UNIT_FAHRENHEIT: raise ValueError( f"Unit can only be set to {UNIT_CELSIUS} or {UNIT_FAHRENHEIT}" ) self._unit = unit @property def temp_divider(self): return self._divider if self._divider_set else 0 @temp_divider.setter def temp_divider(self, divider): """Set a divider used to calculate returned temperature. Default=0""" if divider < 0: raise ValueError("Temperature divider must be a positive value") # this check is to avoid that divider is reset from # calculated value when is set to 0 if (self._divider_set and divider < 1) or divider >= 1: self._divider = int(divider) self._divider_set = divider >= 1 @property def curr_temp_divider(self): return self._ct_divider @curr_temp_divider.setter def curr_temp_divider(self, divider): """Set a divider used to calculate returned current temperature If not defined standard temperature divider is used""" if divider < 0: raise ValueError("Current temperature divider must be a positive value") self._ct_divider = int(divider) def has_decimal(self): """Return if temperature values support decimal""" return self._divider >= 10 def temperature_unit(self): """Return the temperature unit for the device""" if not self._unit: self._unit = self.data.get("temp_unit", UNIT_CELSIUS) return self._unit def current_humidity(self): pass def target_humidity(self): pass def current_operation(self): return self.data.get("mode") def operation_list(self): return self.data.get("support_mode") def current_temperature(self): """Return current temperature for the device""" curr_temp = self._set_decimal( self.data.get("current_temperature"), self._ct_divider ) # when current temperature is not available, target temperature is returned if curr_temp is None: return self.target_temperature() return curr_temp def target_temperature(self): """Return target temperature for the device""" return self._set_decimal(self.data.get("temperature")) def target_temperature_step(self): if self.has_decimal(): return STEP_HALVES return STEP_WHOLE def supported_temperature_steps(self): return TEMP_STEPS def current_fan_mode(self): """Return the fan setting.""" fan_speed = self.data.get("windspeed") if fan_speed is None: return None if fan_speed == "1": return "low" elif fan_speed == "2": return "medium" elif fan_speed == "3": return "high" return fan_speed def fan_list(self): """Return the list of available fan modes.""" return ["low", "medium", "high"] def current_swing_mode(self): """Return the fan setting.""" return None def swing_list(self): """Return the list of available swing modes.""" return None def min_temp(self): return self._set_decimal(self.data.get("min_temper")) def max_temp(self): return self._set_decimal(self.data.get("max_temper")) def min_humidity(self): pass def max_humidity(self): pass def set_temperature(self, temperature, use_divider=True): """Set new target temperature.""" # the value used to set temperature is scaled based on the configured divider divider = self._divider or 1 input_val = float(temperature) scaled_val = input_val * divider digits1 = None if input_val.is_integer() else 1 digits2 = None if scaled_val.is_integer() else 1 set_val = round(scaled_val, digits2) if use_divider: temp_val = round(input_val, digits1) else: temp_val = set_val if self._control_device("temperatureSet", {"value": temp_val}): self._update_data("temperature", set_val) def set_humidity(self, humidity): """Set new target humidity.""" raise NotImplementedError() def set_fan_mode(self, fan_mode): """Set new target fan mode.""" if self._control_device("windSpeedSet", {"value": fan_mode}): fanList = self.fan_list() if fan_mode in fanList: val = str(fanList.index(fan_mode) + 1) else: val = fan_mode self._update_data("windspeed", val) def set_operation_mode(self, operation_mode): """Set new target operation mode.""" if self._control_device("modeSet", {"value": operation_mode}): self._update_data("mode", operation_mode) def set_swing_mode(self, swing_mode): """Set new target swing operation.""" raise NotImplementedError() def support_target_temperature(self): if self.data.get("temperature") is not None: return True else: return False def support_mode(self): if self.data.get("mode") is not None: return True else: return False def support_wind_speed(self): if self.data.get("windspeed") is not None: return True else: return False def support_humidity(self): if self.data.get("humidity") is not None: return True else: return False def turn_on(self): if self._control_device("turnOnOff", {"value": "1"}): self._update_data("state", "true") def turn_off(self): if self._control_device("turnOnOff", {"value": "0"}): self._update_data("state", "false") ================================================ FILE: tuyaha/devices/cover.py ================================================ from tuyaha.devices.base import TuyaDevice class TuyaCover(TuyaDevice): def state(self): state = self.data.get("state") return state def open_cover(self): """Open the cover.""" if self._control_device("turnOnOff", {"value": "1"}): self._update_data("state", 1) def close_cover(self): """Close cover.""" if self._control_device("turnOnOff", {"value": "0"}): self._update_data("state", 2) def stop_cover(self): """Stop the cover.""" if self._control_device("startStop", {"value": "0"}): self._update_data("state", 3) def support_stop(self): support = self.data.get("support_stop") if support is None: return False return support ================================================ FILE: tuyaha/devices/factory.py ================================================ from tuyaha.devices.climate import TuyaClimate from tuyaha.devices.cover import TuyaCover from tuyaha.devices.fan import TuyaFanDevice from tuyaha.devices.light import TuyaLight from tuyaha.devices.lock import TuyaLock from tuyaha.devices.scene import TuyaScene from tuyaha.devices.switch import TuyaSwitch def get_tuya_device(data, api): dev_type = data.get("dev_type") devices = [] if dev_type == "light": devices.append(TuyaLight(data, api)) elif dev_type == "climate": devices.append(TuyaClimate(data, api)) elif dev_type == "scene": devices.append(TuyaScene(data, api)) elif dev_type == "fan": devices.append(TuyaFanDevice(data, api)) elif dev_type == "cover": devices.append(TuyaCover(data, api)) elif dev_type == "lock": devices.append(TuyaLock(data, api)) elif dev_type == "switch": devices.append(TuyaSwitch(data, api)) return devices ================================================ FILE: tuyaha/devices/fan.py ================================================ from tuyaha.devices.base import TuyaDevice class TuyaFanDevice(TuyaDevice): def speed(self): return self.data.get("speed") def speed_list(self): speed_list = [] speed_level = self.data.get("speed_level") for i in range(speed_level): speed_list.append(str(i + 1)) return speed_list def oscillating(self): return self.data.get("direction") def set_speed(self, speed): if self._control_device("windSpeedSet", {"value": speed}): self._update_data("speed", speed) def oscillate(self, oscillating): if oscillating: command = "swingOpen" else: command = "swingClose" if self._control_device(command): self._update_data("direction", oscillating) def turn_on(self): if self._control_device("turnOnOff", {"value": "1"}): self._update_data("state", "true") def turn_off(self): if self._control_device("turnOnOff", {"value": "0"}): self._update_data("state", "false") def support_oscillate(self): if self.oscillating() is None: return False else: return True def support_direction(self): return False ================================================ FILE: tuyaha/devices/light.py ================================================ from tuyaha.devices.base import TuyaDevice # The minimum brightness value set in the API that does not turn off the light MIN_BRIGHTNESS = 10.3 # the default range used to return brightness BRIGHTNESS_STD_RANGE = (1, 255) # the default range used to set color temperature COLTEMP_SET_RANGE = (1000, 10000) # the default range used to return color temperature (in kelvin) COLTEMP_KELV_RANGE = (2700, 6500) class TuyaLight(TuyaDevice): def __init__(self, data, api): super().__init__(data, api) self._support_color = False self.brightness_white_range = BRIGHTNESS_STD_RANGE self.brightness_color_range = BRIGHTNESS_STD_RANGE self.color_temp_range = COLTEMP_SET_RANGE # if color support is not reported by API can be forced by this method # the attribute _support_color is used by method support_color() def force_support_color(self): self._support_color = True def _color_mode(self): work_mode = self.data.get("color_mode", "white") return True if work_mode == "colour" else False @staticmethod def _scale(val, src, dst): """Scale the given value from the scale of src to the scale of dst.""" if val < 0: return dst[0] return ((val - src[0]) / (src[1] - src[0])) * (dst[1] - dst[0]) + dst[0] def brightness(self): """Return the brightness based on the light status scaled to standard range""" brightness = -1 if self._color_mode(): if "color" in self.data: brightness = int(self.data.get("color").get("brightness", "-1")) else: brightness = int(self.data.get("brightness", "-1")) # returned value is scaled using standard range ret_val = TuyaLight._scale( brightness, self._brightness_range(), BRIGHTNESS_STD_RANGE, ) return round(ret_val) def _set_brightness(self, brightness): if self._color_mode(): data = self.data.get("color", {}) data["brightness"] = brightness self._update_data("color", data, force_val=True) else: self._update_data("brightness", brightness) def _brightness_range(self): """return the configured brightness range based on the light status""" if self._color_mode(): return self.brightness_color_range else: return self.brightness_white_range def support_color(self): """return if the light support color""" if not self._support_color: if self.data.get("color") or self.data.get("color_mode") == "colour": self._support_color = True return self._support_color def support_color_temp(self): """return if the light support color temperature""" return self.data.get("color_temp") is not None def hs_color(self): """return current hs color""" if self.support_color(): color = self.data.get("color") if self._color_mode() and color: return color.get("hue", 0.0), float(color.get("saturation", 0.0)) * 100 else: return 0.0, 0.0 else: return None def color_temp(self): """return current color temperature scaled with standard kelvin range""" temp = self.data.get("color_temp") # convert color temperature to kelvin scale for returned value ret_value = TuyaLight._scale( temp, self.color_temp_range, COLTEMP_KELV_RANGE, ) return round(ret_value) def min_color_temp(self): return COLTEMP_KELV_RANGE[1] def max_color_temp(self): return COLTEMP_KELV_RANGE[0] def turn_on(self): if self._control_device("turnOnOff", {"value": "1"}): self._update_data("state", "true") def turn_off(self): if self._control_device("turnOnOff", {"value": "0"}): self._update_data("state", "false") def set_brightness(self, brightness): """Set the brightness(0-255) of light.""" if int(brightness) > 0: # convert to scale 0-100 with MIN_BRIGHTNESS to set the value set_value = TuyaLight._scale( brightness, BRIGHTNESS_STD_RANGE, (MIN_BRIGHTNESS, 100), ) if self._control_device("brightnessSet", {"value": round(set_value, 1)}): self._update_data("state", "true") # convert to scale configured for brightness range to update the cache value = TuyaLight._scale( brightness, BRIGHTNESS_STD_RANGE, self._brightness_range(), ) self._set_brightness(round(value)) else: self.turn_off() def set_color(self, color): """Set the color of light.""" cur_brightness = self.data.get("color", {}).get( "brightness", self.brightness_color_range[0] ) hsv_color = { "hue": color[0] if color[1] != 0 else 0, # color white "saturation": color[1] / 100, } if len(color) < 3: hsv_color["brightness"] = cur_brightness else: hsv_color["brightness"] = color[2] # color white white_mode = hsv_color["saturation"] == 0 is_color = self._color_mode() if self._control_device("colorSet", {"color": hsv_color}): self._update_data("state", "true") self._update_data("color", hsv_color, force_val=True) if not is_color and not white_mode: self._update_data("color_mode", "colour") elif is_color and white_mode: self._update_data("color_mode", "white") def set_color_temp(self, color_temp): """Set the color temperature of light.""" # convert to scale configured for color temperature to update the value set_value = TuyaLight._scale( color_temp, COLTEMP_KELV_RANGE, COLTEMP_SET_RANGE, ) if self._control_device("colorTemperatureSet", {"value": round(set_value)}): self._update_data("state", "true") self._update_data("color_mode", "white") # convert to scale configured for color temperature to update the cache data_value = TuyaLight._scale( color_temp, COLTEMP_KELV_RANGE, self.color_temp_range, ) self._update_data("color_temp", round(data_value)) ================================================ FILE: tuyaha/devices/lock.py ================================================ from tuyaha.devices.base import TuyaDevice class TuyaLock(TuyaDevice): def state(self): state = self.data.get("state") if state == "true": return True elif state == "false": return False else: return None ================================================ FILE: tuyaha/devices/remote.py ================================================ ================================================ FILE: tuyaha/devices/scene.py ================================================ from tuyaha.devices.base import TuyaDevice class TuyaScene(TuyaDevice): def available(self): return True def activate(self): self.api.device_control(self.obj_id, "turnOnOff", {"value": "1"}) def update(self, use_discovery=True): return True ================================================ FILE: tuyaha/devices/switch.py ================================================ from tuyaha.devices.base import TuyaDevice class TuyaSwitch(TuyaDevice): def turn_on(self): if self._control_device("turnOnOff", {"value": "1"}): self._update_data("state", True) def turn_off(self): if self._control_device("turnOnOff", {"value": "0"}): self._update_data("state", False) def update(self, use_discovery=True): return self._update(use_discovery=True) ================================================ FILE: tuyaha/tuyaapi.py ================================================ import logging import time import requests from datetime import datetime from requests.exceptions import ConnectionError as RequestsConnectionError from requests.exceptions import HTTPError as RequestsHTTPError from threading import Lock from tuyaha.devices.factory import get_tuya_device TUYACLOUDURL = "https://px1.tuya{}.com" DEFAULTREGION = "us" # Tuya API do not allow call to discovery command below specific limits # Use discovery_interval property to set correct value based on API discovery limits # Next 2 parameter define the default and minimum allowed value for the property MIN_DISCOVERY_INTERVAL = 10.0 DEF_DISCOVERY_INTERVAL = 60.0 # Tuya API do not allow call to query command below specific limits # Use query_interval property to set correct value based on API query limits # Next 2 parameter define the default and minimum allowed value for the property MIN_QUERY_INTERVAL = 10.0 DEF_QUERY_INTERVAL = 30.0 REFRESHTIME = 60 * 60 * 12 _LOGGER = logging.getLogger(__name__) lock = Lock() class TuyaSession: username = "" password = "" countryCode = "" bizType = "" accessToken = "" refreshToken = "" expireTime = 0 devices = [] region = DEFAULTREGION SESSION = TuyaSession() class TuyaApi: def __init__(self): self._requestSession = None self._discovered_devices = None self._last_discovery = None self._force_discovery = False self._discovery_interval = DEF_DISCOVERY_INTERVAL self._query_interval = DEF_QUERY_INTERVAL self._discovery_fail_count = 0 @property def discovery_interval(self): """The interval in seconds between 2 consecutive device discovery""" return self._discovery_interval @discovery_interval.setter def discovery_interval(self, val): if val < MIN_DISCOVERY_INTERVAL: raise ValueError( f"Discovery interval below {MIN_DISCOVERY_INTERVAL} seconds is invalid" ) self._discovery_interval = val @property def query_interval(self): """The interval in seconds between 2 consecutive device query""" return self._query_interval @query_interval.setter def query_interval(self, val): if val < MIN_QUERY_INTERVAL: raise ValueError( f"Query interval below {MIN_QUERY_INTERVAL} seconds is invalid" ) self._query_interval = val def init(self, username, password, countryCode, bizType="", region=DEFAULTREGION): SESSION.username = username SESSION.password = password SESSION.countryCode = countryCode SESSION.bizType = bizType SESSION.region = region self._requestSession = requests.Session() if username is None or password is None: return None else: self.get_access_token() self.discover_devices() return SESSION.devices def get_access_token(self): try: response = self._requestSession.post( (TUYACLOUDURL + "/homeassistant/auth.do").format(SESSION.region), data={ "userName": SESSION.username, "password": SESSION.password, "countryCode": SESSION.countryCode, "bizType": SESSION.bizType, "from": "tuya", }, ) response.raise_for_status() except RequestsConnectionError as ex: raise TuyaNetException from ex except RequestsHTTPError as ex: if response.status_code >= 500: raise TuyaServerException from ex response_json = response.json() if response_json.get("responseStatus") == "error": message = response_json.get("errorMsg") if message == "error": raise TuyaAPIException("get access token failed") elif message == "you cannot auth exceed once in 60 seconds": raise TuyaAPIRateLimitException("login rate limited") else: raise TuyaAPIException(message) SESSION.accessToken = response_json.get("access_token") SESSION.refreshToken = response_json.get("refresh_token") SESSION.expireTime = int(time.time()) + response_json.get("expires_in") areaCode = SESSION.accessToken[0:2] if areaCode == "AY": SESSION.region = "cn" elif areaCode == "EU": SESSION.region = "eu" else: SESSION.region = "us" def check_access_token(self): if SESSION.username == "" or SESSION.password == "": raise TuyaAPIException("can not find username or password") if SESSION.accessToken == "" or SESSION.refreshToken == "": self.get_access_token() self._force_discovery = True elif SESSION.expireTime <= REFRESHTIME + int(time.time()): self.refresh_access_token() self._force_discovery = True def refresh_access_token(self): data = "grant_type=refresh_token&refresh_token=" + SESSION.refreshToken response = self._requestSession.get( (TUYACLOUDURL + "/homeassistant/access.do").format(SESSION.region) + "?" + data ) response_json = response.json() if response_json.get("responseStatus") == "error": raise TuyaAPIException("refresh token failed") SESSION.accessToken = response_json.get("access_token") SESSION.refreshToken = response_json.get("refresh_token") SESSION.expireTime = int(time.time()) + response_json.get("expires_in") def poll_devices_update(self): self.check_access_token() return self.discover_devices() def update_device_data(self, dev_id, data): for device in self._discovered_devices: if device["id"] == dev_id: device["data"] = data def _call_discovery(self): if not self._last_discovery or self._force_discovery: self._force_discovery = False return True difference = (datetime.now() - self._last_discovery).total_seconds() if difference > self.discovery_interval: return True return False # if discovery is called before that configured polling interval has passed # it return cached data retrieved by previous successful call def discovery(self): with lock: if self._call_discovery(): try: response = self._request("Discovery", "discovery") finally: self._last_discovery = datetime.now() if response: result_code = response["header"]["code"] if result_code == "SUCCESS": self._discovery_fail_count = 0 self._discovered_devices = response["payload"]["devices"] self._load_session_devices() else: _LOGGER.debug("Discovery: Use cached info") return self._discovered_devices def _load_session_devices(self): SESSION.devices = [] for device in self._discovered_devices: SESSION.devices.extend(get_tuya_device(device, self)) def discover_devices(self): devices = self.discovery() if not devices: return None return devices def get_devices_by_type(self, dev_type): device_list = [] for device in SESSION.devices: if device.device_type() == dev_type: device_list.append(device) return device_list def get_all_devices(self): return SESSION.devices def get_device_by_id(self, dev_id): for device in SESSION.devices: if device.object_id() == dev_id: return device return None def device_control(self, devId, action, param=None, namespace="control"): if param is None: param = {} response = self._request(action, namespace, devId, param) if response and response["header"]["code"] == "SUCCESS": success = True else: success = False return success, response def _request(self, name, namespace, devId=None, payload={}): header = {"name": name, "namespace": namespace, "payloadVersion": 1} payload["accessToken"] = SESSION.accessToken if namespace != "discovery": payload["devId"] = devId data = {"header": header, "payload": payload} try: response = self._requestSession.post( (TUYACLOUDURL + "/homeassistant/skill").format(SESSION.region), json=data ) except RequestsConnectionError as ex: _LOGGER.warning( "request error, error code is %s, device %s", ex, devId, ) return if not response.ok: _LOGGER.warning( "request error, status code is %d, device %s", response.status_code, devId, ) return response_json = response.json() result_code = response_json["header"]["code"] if result_code != "SUCCESS": if result_code == "FrequentlyInvoke": self._raise_frequently_invoke( name, response_json["header"].get("msg", result_code), devId ) else: _LOGGER.warning( "control device error, error code is " + response_json["header"]["code"] ) return response_json def _raise_frequently_invoke(self, action, error_msg, dev_id): if action == "Discovery": self._discovery_fail_count += 1 text = ( "Method [Discovery] fails {} time(s) using poll interval {} - error: {}" ) message = text.format( self._discovery_fail_count, self.discovery_interval, error_msg ) else: text = "Method [{}] for device {} fails {}- error: {}" msg_interval = "" if action == "QueryDevice": msg_interval = "using poll interval {} ".format(self.query_interval) message = text.format(action, dev_id, msg_interval, error_msg) raise TuyaFrequentlyInvokeException(message) class TuyaAPIException(Exception): pass class TuyaNetException(Exception): pass class TuyaServerException(Exception): pass class TuyaFrequentlyInvokeException(Exception): pass class TuyaAPIRateLimitException(Exception): pass