Full Code of PaulAnnekov/tuyaha for AI

master 8a487d704cec cached
21 files
37.6 KB
9.0k tokens
125 symbols
1 requests
Download .txt
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
Download .txt
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
Download .txt
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.

Copied to clipboard!