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
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
SYMBOL INDEX (125 symbols across 10 files)
FILE: tuyaha/devices/base.py
class TuyaDevice (line 5) | class TuyaDevice:
method __init__ (line 7) | def __init__(self, data, api):
method name (line 19) | def name(self):
method state (line 22) | def state(self):
method device_type (line 33) | def device_type(self):
method object_id (line 36) | def object_id(self):
method object_type (line 39) | def object_type(self):
method available (line 42) | def available(self):
method iconurl (line 45) | def iconurl(self):
method _update_data (line 48) | def _update_data(self, key, value, force_val=False):
method _control_device (line 58) | def _control_device(self, action, param=None):
method _update (line 72) | def _update(self, use_discovery):
method __repr__ (line 120) | def __repr__(self):
method update (line 133) | def update(self, use_discovery=True):
FILE: tuyaha/devices/climate.py
class TuyaClimate (line 17) | class TuyaClimate(TuyaDevice):
method __init__ (line 19) | def __init__(self, data, api):
method _set_decimal (line 31) | def _set_decimal(self, val, divider=0):
method set_unit (line 50) | def set_unit(self, unit):
method temp_divider (line 59) | def temp_divider(self):
method temp_divider (line 63) | def temp_divider(self, divider):
method curr_temp_divider (line 74) | def curr_temp_divider(self):
method curr_temp_divider (line 78) | def curr_temp_divider(self, divider):
method has_decimal (line 85) | def has_decimal(self):
method temperature_unit (line 89) | def temperature_unit(self):
method current_humidity (line 95) | def current_humidity(self):
method target_humidity (line 98) | def target_humidity(self):
method current_operation (line 101) | def current_operation(self):
method operation_list (line 104) | def operation_list(self):
method current_temperature (line 107) | def current_temperature(self):
method target_temperature (line 117) | def target_temperature(self):
method target_temperature_step (line 121) | def target_temperature_step(self):
method supported_temperature_steps (line 126) | def supported_temperature_steps(self):
method current_fan_mode (line 129) | def current_fan_mode(self):
method fan_list (line 142) | def fan_list(self):
method current_swing_mode (line 146) | def current_swing_mode(self):
method swing_list (line 150) | def swing_list(self):
method min_temp (line 154) | def min_temp(self):
method max_temp (line 157) | def max_temp(self):
method min_humidity (line 160) | def min_humidity(self):
method max_humidity (line 163) | def max_humidity(self):
method set_temperature (line 166) | def set_temperature(self, temperature, use_divider=True):
method set_humidity (line 185) | def set_humidity(self, humidity):
method set_fan_mode (line 189) | def set_fan_mode(self, fan_mode):
method set_operation_mode (line 199) | def set_operation_mode(self, operation_mode):
method set_swing_mode (line 204) | def set_swing_mode(self, swing_mode):
method support_target_temperature (line 208) | def support_target_temperature(self):
method support_mode (line 214) | def support_mode(self):
method support_wind_speed (line 220) | def support_wind_speed(self):
method support_humidity (line 226) | def support_humidity(self):
method turn_on (line 232) | def turn_on(self):
method turn_off (line 236) | def turn_off(self):
FILE: tuyaha/devices/cover.py
class TuyaCover (line 4) | class TuyaCover(TuyaDevice):
method state (line 6) | def state(self):
method open_cover (line 10) | def open_cover(self):
method close_cover (line 15) | def close_cover(self):
method stop_cover (line 20) | def stop_cover(self):
method support_stop (line 25) | def support_stop(self):
FILE: tuyaha/devices/factory.py
function get_tuya_device (line 10) | def get_tuya_device(data, api):
FILE: tuyaha/devices/fan.py
class TuyaFanDevice (line 4) | class TuyaFanDevice(TuyaDevice):
method speed (line 6) | def speed(self):
method speed_list (line 9) | def speed_list(self):
method oscillating (line 16) | def oscillating(self):
method set_speed (line 19) | def set_speed(self, speed):
method oscillate (line 23) | def oscillate(self, oscillating):
method turn_on (line 31) | def turn_on(self):
method turn_off (line 35) | def turn_off(self):
method support_oscillate (line 39) | def support_oscillate(self):
method support_direction (line 45) | def support_direction(self):
FILE: tuyaha/devices/light.py
class TuyaLight (line 16) | class TuyaLight(TuyaDevice):
method __init__ (line 18) | def __init__(self, data, api):
method force_support_color (line 27) | def force_support_color(self):
method _color_mode (line 30) | def _color_mode(self):
method _scale (line 35) | def _scale(val, src, dst):
method brightness (line 41) | def brightness(self):
method _set_brightness (line 57) | def _set_brightness(self, brightness):
method _brightness_range (line 65) | def _brightness_range(self):
method support_color (line 72) | def support_color(self):
method support_color_temp (line 79) | def support_color_temp(self):
method hs_color (line 83) | def hs_color(self):
method color_temp (line 94) | def color_temp(self):
method min_color_temp (line 105) | def min_color_temp(self):
method max_color_temp (line 108) | def max_color_temp(self):
method turn_on (line 111) | def turn_on(self):
method turn_off (line 115) | def turn_off(self):
method set_brightness (line 119) | def set_brightness(self, brightness):
method set_color (line 140) | def set_color(self, color):
method set_color_temp (line 164) | def set_color_temp(self, color_temp):
FILE: tuyaha/devices/lock.py
class TuyaLock (line 4) | class TuyaLock(TuyaDevice):
method state (line 5) | def state(self):
FILE: tuyaha/devices/scene.py
class TuyaScene (line 4) | class TuyaScene(TuyaDevice):
method available (line 5) | def available(self):
method activate (line 8) | def activate(self):
method update (line 11) | def update(self, use_discovery=True):
FILE: tuyaha/devices/switch.py
class TuyaSwitch (line 5) | class TuyaSwitch(TuyaDevice):
method turn_on (line 7) | def turn_on(self):
method turn_off (line 11) | def turn_off(self):
method update (line 15) | def update(self, use_discovery=True):
FILE: tuyaha/tuyaapi.py
class TuyaSession (line 33) | class TuyaSession:
class TuyaApi (line 49) | class TuyaApi:
method __init__ (line 51) | def __init__(self):
method discovery_interval (line 61) | def discovery_interval(self):
method discovery_interval (line 66) | def discovery_interval(self, val):
method query_interval (line 74) | def query_interval(self):
method query_interval (line 79) | def query_interval(self, val):
method init (line 86) | def init(self, username, password, countryCode, bizType="", region=DEF...
method get_access_token (line 102) | def get_access_token(self):
method check_access_token (line 142) | def check_access_token(self):
method refresh_access_token (line 152) | def refresh_access_token(self):
method poll_devices_update (line 167) | def poll_devices_update(self):
method update_device_data (line 171) | def update_device_data(self, dev_id, data):
method _call_discovery (line 176) | def _call_discovery(self):
method discovery (line 187) | def discovery(self):
method _load_session_devices (line 204) | def _load_session_devices(self):
method discover_devices (line 209) | def discover_devices(self):
method get_devices_by_type (line 215) | def get_devices_by_type(self, dev_type):
method get_all_devices (line 222) | def get_all_devices(self):
method get_device_by_id (line 225) | def get_device_by_id(self, dev_id):
method device_control (line 231) | def device_control(self, devId, action, param=None, namespace="control"):
method _request (line 241) | def _request(self, name, namespace, devId=None, payload={}):
method _raise_frequently_invoke (line 279) | def _raise_frequently_invoke(self, action, error_msg, dev_id):
class TuyaAPIException (line 298) | class TuyaAPIException(Exception):
class TuyaNetException (line 302) | class TuyaNetException(Exception):
class TuyaServerException (line 306) | class TuyaServerException(Exception):
class TuyaFrequentlyInvokeException (line 310) | class TuyaFrequentlyInvokeException(Exception):
class TuyaAPIRateLimitException (line 314) | class TuyaAPIRateLimitException(Exception):
Condensed preview — 21 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (41K chars).
[
{
"path": ".drone.yml",
"chars": 269,
"preview": "kind: pipeline\nname: default\n\nsteps:\n- name: publish\n image: plugins/pypi\n settings:\n username:\n from_secret: "
},
{
"path": ".gitignore",
"chars": 6,
"preview": ".idea\n"
},
{
"path": ".vscode/settings.json",
"chars": 142,
"preview": "{\n \"cSpell.words\": [\n \"annekov\",\n \"pavlo\",\n \"tuya\",\n \"tuyacloudurl\",\n \"tuyaha\",\n "
},
{
"path": "LICENSE",
"chars": 1239,
"preview": "Copyright for portions of project tuyaha are held by Tuya Inc., 2018 as part\nof project tuyapy. All other copyright for "
},
{
"path": "MANIFEST.in",
"chars": 16,
"preview": "include LICENSE\n"
},
{
"path": "README.md",
"chars": 1426,
"preview": "# tuyaha\n\n## This library was maintained exclusively for Home Assistant Tuya component. As HA 2021.10.0 switched to Tuya"
},
{
"path": "setup.py",
"chars": 782,
"preview": "# coding=utf-8\nimport setuptools\n\nwith open(\"README.md\", \"r\") as fh:\n long_description = fh.read()\n\nsetuptools.setup("
},
{
"path": "tools/debug_discovery.py",
"chars": 1418,
"preview": "# The script is intended to get a list of all devices available via Tuya Home Assistant API endpoint.\nimport requests\nim"
},
{
"path": "tuyaha/__init__.py",
"chars": 54,
"preview": "\"\"\"Init file for test\"\"\"\nfrom .tuyaapi import TuyaApi\n"
},
{
"path": "tuyaha/devices/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "tuyaha/devices/base.py",
"chars": 4225,
"preview": "import time\nfrom datetime import datetime\n\n\nclass TuyaDevice:\n\n def __init__(self, data, api):\n self.api = api"
},
{
"path": "tuyaha/devices/climate.py",
"chars": 7466,
"preview": "from tuyaha.devices.base import TuyaDevice\n\nUNIT_CELSIUS = \"CELSIUS\"\nUNIT_FAHRENHEIT = \"FAHRENHEIT\"\n\nSTEP_WHOLE = 1\nSTEP"
},
{
"path": "tuyaha/devices/cover.py",
"chars": 790,
"preview": "from tuyaha.devices.base import TuyaDevice\n\n\nclass TuyaCover(TuyaDevice):\n\n def state(self):\n state = self.dat"
},
{
"path": "tuyaha/devices/factory.py",
"chars": 943,
"preview": "from tuyaha.devices.climate import TuyaClimate\nfrom tuyaha.devices.cover import TuyaCover\nfrom tuyaha.devices.fan import"
},
{
"path": "tuyaha/devices/fan.py",
"chars": 1264,
"preview": "from tuyaha.devices.base import TuyaDevice\n\n\nclass TuyaFanDevice(TuyaDevice):\n\n def speed(self):\n return self."
},
{
"path": "tuyaha/devices/light.py",
"chars": 6674,
"preview": "from tuyaha.devices.base import TuyaDevice\n\n# The minimum brightness value set in the API that does not turn off the lig"
},
{
"path": "tuyaha/devices/lock.py",
"chars": 279,
"preview": "from tuyaha.devices.base import TuyaDevice\n\n\nclass TuyaLock(TuyaDevice):\n def state(self):\n state = self.data."
},
{
"path": "tuyaha/devices/remote.py",
"chars": 0,
"preview": ""
},
{
"path": "tuyaha/devices/scene.py",
"chars": 281,
"preview": "from tuyaha.devices.base import TuyaDevice\n\n\nclass TuyaScene(TuyaDevice):\n def available(self):\n return True\n\n"
},
{
"path": "tuyaha/devices/switch.py",
"chars": 431,
"preview": "\nfrom tuyaha.devices.base import TuyaDevice\n\n\nclass TuyaSwitch(TuyaDevice):\n\n def turn_on(self):\n if self._con"
},
{
"path": "tuyaha/tuyaapi.py",
"chars": 10772,
"preview": "import logging\nimport time\n\nimport requests\nfrom datetime import datetime\nfrom requests.exceptions import ConnectionErro"
}
]
About this extraction
This page contains the full source code of the PaulAnnekov/tuyaha GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 21 files (37.6 KB), approximately 9.0k tokens, and a symbol index with 125 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.