├── setup.cfg ├── pyproject.toml ├── docs ├── TinyTuya-diagram.jpg ├── TinyTuya-diagram.png └── README.md ├── tinytuya ├── core │ ├── exceptions.py │ ├── __init__.py │ ├── const.py │ ├── header.py │ ├── udp_helper.py │ ├── command_types.py │ ├── error_helper.py │ ├── Device.py │ └── message_helper.py ├── Contrib │ ├── __init__.py │ ├── SocketDevice.py │ ├── PresenceDetectorDevice.py │ ├── AtorchTemperatureControllerDevice.py │ ├── DoorbellDevice.py │ ├── BlanketDevice.py │ └── ClimateDevice.py ├── CoverDevice.py ├── OutletDevice.py ├── __init__.py └── __main__.py ├── server ├── mqtt │ ├── mqtt.json │ └── mqtt_gateway.py ├── Dockerfile ├── web │ ├── tinytuya.css │ ├── device.html │ └── device_dps.html └── README.md ├── requirements.txt ├── SECURITY.md ├── .github └── workflows │ ├── pylint.yml │ ├── contrib.yml │ ├── test.yml │ └── codeql-analysis.yml ├── examples ├── Contrib │ ├── RFRemoteControlDevice-example.py │ ├── DoorbellDevice-example.py │ ├── PresenceDetectorDevice-example.py │ ├── TuyaSmartPlug-example.py │ ├── XmCosyStringLight-example.py │ ├── ThermostatDevice-example.py │ └── IRRemoteControlDevice-example.py ├── entrypoint.sh ├── getstatus.py ├── Dockerfile ├── cloudrequest.py ├── send_raw_dps.py ├── cloud_rgb_bulb.py ├── zigbee_gateway.py ├── snapshot.py ├── async_send_receive.py ├── README.md ├── cloud.py ├── cloud_ir.py ├── bulb-music.py ├── bulb-scenes.py ├── monitor.py ├── devices.py ├── bulb.py ├── threading.py ├── galaxy_projector.py └── multi-select.py ├── .pylintrc ├── LICENSE ├── tools ├── README.md ├── fake-v35-device.py └── broadcast-relay.py ├── DP_Mapping.md ├── setup.py ├── .gitignore ├── test.py └── testcontrib.py /setup.cfg: -------------------------------------------------------------------------------- 1 | [egg_info] 2 | tag_build = 3 | tag_date = 0 4 | tag_svn_revision = 0 5 | 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "colorama", "requests", "cryptography"] 3 | -------------------------------------------------------------------------------- /docs/TinyTuya-diagram.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonacox/tinytuya/HEAD/docs/TinyTuya-diagram.jpg -------------------------------------------------------------------------------- /docs/TinyTuya-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonacox/tinytuya/HEAD/docs/TinyTuya-diagram.png -------------------------------------------------------------------------------- /tinytuya/core/exceptions.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Module 2 | # -*- coding: utf-8 -*- 3 | 4 | class DecodeError(Exception): 5 | pass 6 | -------------------------------------------------------------------------------- /server/mqtt/mqtt.json: -------------------------------------------------------------------------------- 1 | { 2 | "broker" : "localhost", 3 | "port" : 1883, 4 | "username" : "Broker_Username", 5 | "password" : "Broker_Password", 6 | "topic" : "tinytuya", 7 | "pollingtime" : 5 8 | } 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | cryptography>=3.1 # Encryption - AES can also be provided via PyCryptodome or pyaes or pyca/cryptography 3 | requests # Used for Setup Wizard - Tuya IoT Platform calls 4 | colorama # Makes ANSI escape character sequences work under MS Windows. 5 | #netifaces # Used to get the IP address of the local machine for scanning for devices, mainly useful for multi-interface machines. 6 | -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-bookworm 2 | WORKDIR /app 3 | ENV PYTHONUNBUFFERED=1 4 | ENV CRYPTOGRAPHY_DONT_BUILD_RUST=1 5 | RUN pip install --no-cache-dir --upgrade pip setuptools wheel 6 | RUN pip install --no-cache-dir cryptography pycryptodome 7 | RUN pip install --no-cache-dir netifaces tinytuya psutil colorama requests 8 | COPY . . 9 | CMD ["python3", "server.py"] 10 | EXPOSE 8888 11 | EXPOSE 6666/udp 12 | EXPOSE 6667/udp 13 | EXPOSE 7000/udp 14 | -------------------------------------------------------------------------------- /tinytuya/core/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | # TinyTuya Module 3 | # -*- coding: utf-8 -*- 4 | 5 | from .crypto_helper import * 6 | from .message_helper import * 7 | from .exceptions import * 8 | from .error_helper import * 9 | from .const import * 10 | from .XenonDevice import * 11 | from .udp_helper import * 12 | from .Device import * 13 | from .command_types import * 14 | from .header import * 15 | 16 | from .core import * 17 | from .core import __version__ 18 | from .core import __author__ 19 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Bug fixes and patches will be applied to versions 1.0 and above. 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | 1.0 + | :white_check_mark: | 10 | | < 1.0 | :x: | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | Please open an [issue](https://github.com/jasonacox/tinytuya/issues) or a [pull request](https://github.com/jasonacox/tinytuya/pulls) for any vulnerability discovered. 15 | -------------------------------------------------------------------------------- /.github/workflows/pylint.yml: -------------------------------------------------------------------------------- 1 | name: Pylint 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.8", "3.9", "3.10"] 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install pylint 21 | pip install -r requirements.txt 22 | - name: Analyzing the code with pylint 23 | run: | 24 | pylint --recursive y -E tinytuya/ 25 | -------------------------------------------------------------------------------- /examples/Contrib/RFRemoteControlDevice-example.py: -------------------------------------------------------------------------------- 1 | #import tinytuya 2 | #tinytuya.set_debug() 3 | 4 | from tinytuya.Contrib import RFRemoteControlDevice 5 | 6 | d = RFRemoteControlDevice.RFRemoteControlDevice( 'abcdefghijklmnop123456', '172.28.321.475', '1234567890123abc', persist=True ) 7 | 8 | print( 'Please hold remote close to device and press and hold a button' ) 9 | print( 'Waiting for button press...' ) 10 | 11 | button = d.rf_receive_button() 12 | 13 | if not button: 14 | print( 'No button received!' ) 15 | else: 16 | print( 'Learned button:', button ) 17 | print( 'Decoded:', d.rf_print_button( button ) ) 18 | print( 'Transmitting learned button...' ) 19 | d.rf_send_button( button ) 20 | print( 'Done!' ) 21 | -------------------------------------------------------------------------------- /examples/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # jasonacox/tinytuya-cli:latest 4 | # 5 | # TinyTuya entrypoint script for a Docker container 6 | # This script will run the wizard or a scan based on user input 7 | # 8 | # Author: Jason A. Cox 9 | # For more information see https://github.com/jasonacox/tinytuya 10 | 11 | # Ask users if they want to run wizard or a scan 12 | read -n 1 -r -p "TinyTuya (w)izard, (s)can or (b)ash shell? [w/s/B] " choice 13 | echo "" 14 | if [[ "$choice" =~ ^([wW])$ ]]; then 15 | echo "Running the wizard..." 16 | python -m tinytuya wizard 17 | elif [[ "$choice" =~ ^([sS])$ ]]; then 18 | echo "Running a scan..." 19 | python -m tinytuya scan 20 | else 21 | echo "Running bash shell..." 22 | /bin/bash 23 | fi 24 | 25 | -------------------------------------------------------------------------------- /tinytuya/Contrib/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Note: This file has been deprecated, please do not add new modules to it. 3 | # Instead, import new modules with `from tinytuya.Contrib import YourNewModule` 4 | # and call with `YourNewModule.YourNewModule(...)` 5 | # 6 | 7 | from .ThermostatDevice import ThermostatDevice 8 | from .IRRemoteControlDevice import IRRemoteControlDevice 9 | from .SocketDevice import SocketDevice 10 | from .DoorbellDevice import DoorbellDevice 11 | from .ClimateDevice import ClimateDevice 12 | from .AtorchTemperatureControllerDevice import AtorchTemperatureControllerDevice 13 | from .InverterHeatPumpDevice import InverterHeatPumpDevice, TemperatureUnit, InverterHeatPumpMode, InverterHeatPumpFault 14 | 15 | DeviceTypes = ["ThermostatDevice", "IRRemoteControlDevice", "SocketDevice", "DoorbellDevice", "ClimateDevice", "AtorchTemperatureControllerDevice", "InverterHeatPumpDevice"] 16 | -------------------------------------------------------------------------------- /.github/workflows/contrib.yml: -------------------------------------------------------------------------------- 1 | name: Contrib Test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | tests: 10 | name: "Python ${{ matrix.python-version }}" 11 | runs-on: "ubuntu-22.04" 12 | 13 | strategy: 14 | matrix: 15 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] 16 | 17 | steps: 18 | - uses: "actions/checkout@v3" 19 | - uses: "actions/setup-python@v4" 20 | with: 21 | python-version: "${{ matrix.python-version }}" 22 | - name: "Install dependencies" 23 | run: | 24 | set -xe 25 | python -VV 26 | python -m site 27 | python -m pip install --upgrade pip setuptools wheel 28 | python -m pip install --upgrade cryptography requests colorama 29 | 30 | - name: "Run testcontrib.py on ${{ matrix.python-version }}" 31 | run: "python -m testcontrib.py" 32 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable=consider-iterating-dictionary, consider-swap-variables, consider-using-enumerate, cyclic-import, consider-using-max-builtin, no-else-continue, consider-using-min-builtin, consider-using-in, super-with-arguments, protected-access, import-outside-toplevel, multiple-statements, unidiomatic-typecheck, no-else-break, import-error, invalid-name, missing-docstring, no-else-return, no-member, too-many-lines, line-too-long, too-many-ancestors, too-many-arguments, too-many-branches, too-many-instance-attributes, too-many-locals, too-many-nested-blocks, too-many-return-statements, too-many-statements, too-few-public-methods, ungrouped-imports, use-dict-literal, superfluous-parens, fixme, consider-using-f-string, bare-except, broad-except, unused-variable, unspecified-encoding, redefined-builtin, consider-using-dict-items, redundant-u-string-prefix, useless-object-inheritance 3 | 4 | [SIMILARITIES] 5 | min-similarity-lines=8 6 | -------------------------------------------------------------------------------- /tinytuya/core/const.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Module 2 | # -*- coding: utf-8 -*- 3 | 4 | # Globals Network Settings 5 | MAXCOUNT = 15 # How many tries before stopping 6 | SCANTIME = 18 # How many seconds to wait before stopping device discovery 7 | UDPPORT = 6666 # Tuya 3.1 UDP Port 8 | UDPPORTS = 6667 # Tuya 3.3 encrypted UDP Port 9 | UDPPORTAPP = 7000 # Tuya app encrypted UDP Port 10 | TCPPORT = 6668 # Tuya TCP Local Port 11 | TIMEOUT = 3.0 # Seconds to wait for a broadcast 12 | TCPTIMEOUT = 0.4 # Seconds to wait for socket open for scanning 13 | DEFAULT_NETWORK = '192.168.0.0/24' 14 | 15 | # Configuration Files 16 | CONFIGFILE = 'tinytuya.json' 17 | DEVICEFILE = 'devices.json' 18 | RAWFILE = 'tuya-raw.json' 19 | SNAPSHOTFILE = 'snapshot.json' 20 | 21 | DEVICEFILE_SAVE_VALUES = ('category', 'product_name', 'product_id', 'biz_type', 'model', 'sub', 'icon', 'version', 'last_ip', 'uuid', 'node_id', 'sn', 'mapping') 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Build Test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | tests: 10 | name: "Python ${{ matrix.python-version }}" 11 | runs-on: "ubuntu-22.04" 12 | 13 | strategy: 14 | matrix: 15 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] 16 | 17 | steps: 18 | - uses: "actions/checkout@v3" 19 | - uses: "actions/setup-python@v4" 20 | with: 21 | python-version: "${{ matrix.python-version }}" 22 | - name: "Install dependencies" 23 | run: | 24 | set -xe 25 | python -VV 26 | python -m site 27 | python -m pip install --upgrade pip setuptools wheel 28 | python -m pip install --upgrade cryptography requests colorama 29 | 30 | - name: "Run test.py and tests.py on ${{ matrix.python-version }}" 31 | run: "python -m test.py && python -m tests" 32 | -------------------------------------------------------------------------------- /examples/getstatus.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Example 2 | # -*- coding: utf-8 -*- 3 | """ 4 | TinyTuya - Example to fetch status of Tuya device 5 | 6 | Author: Jason A. Cox 7 | For more information see https://github.com/jasonacox/tinytuya 8 | 9 | """ 10 | import tinytuya 11 | import time 12 | 13 | # Connect to the device - replace with real values 14 | d=tinytuya.OutletDevice(DEVICEID, DEVICEIP, DEVICEKEY) 15 | d.set_version(3.3) 16 | 17 | # Alternative connection - for some devices with 22 character IDs they require a special handling 18 | # d=tinytuya.OutletDevice(DEVICEID, DEVICEIP, DEVICEKEY, 'device22') 19 | # d.set_dpsUsed({"1": None}) 20 | # d.set_version(3.3) 21 | 22 | # Option for Power Monitoring Smart Plugs - Some require UPDATEDPS to update power data points 23 | # payload = d.generate_payload(tinytuya.UPDATEDPS,['18','19','20']) 24 | # d.send(payload) 25 | # sleep(1) 26 | 27 | # Get the status of the device 28 | # e.g. {'devId': '0071299988f9376255b', 'dps': {'1': True, '3': 208, '101': False}} 29 | data = d.status() 30 | print(data) 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Jason Cox 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/Dockerfile: -------------------------------------------------------------------------------- 1 | # Python base 2 | FROM python:3.10 3 | 4 | # Set the working directory 5 | WORKDIR /app 6 | 7 | # Update default packages 8 | RUN apt-get update 9 | 10 | # Get Ubuntu packages 11 | RUN apt-get install -y \ 12 | build-essential \ 13 | curl 14 | 15 | # Update new packages 16 | RUN apt-get update 17 | 18 | # Get Rust 19 | RUN curl https://sh.rustup.rs -sSf | bash -s -- -y 20 | ENV PATH="/root/.cargo/bin:${PATH}" 21 | 22 | # Install dependencies 23 | COPY entrypoint.sh /bin/entrypoint.sh 24 | RUN curl https://sh.rustup.rs -sSf |sh -s -- -y 25 | RUN pip3 install --no-cache-dir tinytuya 26 | 27 | # Allow UDP traffic 6666, 6667 and 7000 28 | EXPOSE 6666/udp 29 | EXPOSE 6667/udp 30 | EXPOSE 7000/udp 31 | 32 | # Run the application bash entrypoint.sh 33 | ENTRYPOINT ["/bin/entrypoint.sh"] 34 | 35 | # Example Docker build 36 | # docker build -t jasonacox/tinytuya-cli:latest . 37 | 38 | # Example Docker run but with host network 39 | # docker run \ 40 | # -it --rm \ 41 | # -p 6666:6666/udp \ 42 | # -p 6667:6667/udp \ 43 | # -p 7000:7000/udp \ 44 | # -v $(pwd):/app \ 45 | # --name tinytuya-cli \ 46 | # jasonacox/tinytuya-cli:latest 47 | -------------------------------------------------------------------------------- /examples/Contrib/DoorbellDevice-example.py: -------------------------------------------------------------------------------- 1 | # TinyTuya IRRemoteControlDevice Example 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Python module to interface with Tuya WiFi smart devices 5 | 6 | Author: JonesMeUp 7 | Tested: LSC-Bell 8S(AKV300_8M) 8 | Note: Without hack the device can't be used offline. 9 | With hack the DoorbellDevice is useless. 10 | 11 | For more information see https://github.com/jasonacox/tinytuya 12 | https://github.com/jasonacox/tinytuya/issues/162 13 | 14 | """ 15 | import tinytuya 16 | from tinytuya.Contrib import DoorbellDevice 17 | 18 | d = DoorbellDevice('abcdefghijklmnop123456', '192.168.178.25', 19 | '1234567890123abc', 'device22') 20 | d.set_version(3.3) 21 | d.set_socketPersistent(True) # Keep socket connection open between commands 22 | 23 | d.set_volume(3) 24 | d.set_motion_area(0, 5, 50, 50) 25 | d.set_motion_area_switch(True) 26 | 27 | print(" > Begin Monitor Loop <") 28 | while(True): 29 | # See if any data is available 30 | data = d.receive() 31 | print('Data: %r' % data) 32 | # Send keyalive heartbeat 33 | print(" > Send Heartbeat Ping < ") 34 | payload = d.generate_payload(tinytuya.HEART_BEAT) 35 | d.send(payload) -------------------------------------------------------------------------------- /examples/cloudrequest.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Example 2 | # -*- coding: utf-8 -*- 3 | """ 4 | TinyTuya - CloudRequest 5 | 6 | This examples uses the Tinytuya Cloud class and the cloudrequest function 7 | to access the Tuya Cloud to control a door lock. 8 | 9 | 10 | Author: Jason A. Cox 11 | For more information see https://github.com/jasonacox/tinytuya 12 | 13 | """ 14 | import tinytuya 15 | 16 | # Turn on Debug Mode 17 | tinytuya.set_debug(True) 18 | 19 | # You can have tinytuya pull the API credentials 20 | # from the tinytuya.json file created by the wizard 21 | # c = tinytuya.Cloud() 22 | # Alternatively you can specify those values here: 23 | # Connect to Tuya Cloud 24 | c = tinytuya.Cloud( 25 | apiRegion="us", 26 | apiKey="xxxxxxxxxxxxxxxxxxxx", 27 | apiSecret="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 28 | apiDeviceID="xxxxxxxxxxxxxxxxxxID") 29 | 30 | # Example: Door Lock 31 | device_id = "xxxxxxxxxxxxxxxxxxID" 32 | 33 | # Get a password ticket 34 | ticket = c.cloudrequest( f'/v1.0/smart-lock/devices/{device_id}/password-ticket' ) 35 | 36 | # Unlock the door 37 | unlock = c.cloudrequest( f'/v1.1/devices/{device_id}/door-lock/password-free/open-door', 38 | post={'ticket_id': ticket} ) 39 | -------------------------------------------------------------------------------- /examples/Contrib/PresenceDetectorDevice-example.py: -------------------------------------------------------------------------------- 1 | from tinytuya.Contrib import PresenceDetectorDevice 2 | #from tinytuya import core 3 | import time 4 | import logging 5 | import requests 6 | 7 | log = logging.getLogger(__name__) 8 | device_id = 'XXXX' 9 | device_ip = 'YYYY' 10 | local_key = 'ZZZZ' 11 | iftt_url = "https://maker.ifttt.com/trigger/{webhook_name_here}/json/with/key/{key_here}" 12 | 13 | def main(): 14 | setup() 15 | run() 16 | 17 | def setup(): 18 | global device 19 | device = PresenceDetectorDevice.PresenceDetectorDevice(device_id, address=device_ip, local_key=local_key, version=3.3) 20 | 21 | def run(): 22 | log.info(" >>>> Begin Monitor Loop <<<< ") 23 | while(True): 24 | presence = device.get_presence_state() 25 | if (presence == 'presence'): 26 | log.info('ALERT! Presence detected!') 27 | presence_detected_steps() 28 | else: 29 | log.debug('no presence, sleep...') 30 | time.sleep(20) 31 | 32 | def presence_detected_steps(): 33 | requests.post(iftt_url, json={}) 34 | 35 | if __name__ == "__main__": 36 | main() 37 | -------------------------------------------------------------------------------- /examples/send_raw_dps.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Example 2 | # -*- coding: utf-8 -*- 3 | """ 4 | TinyTuya - Example to send raw DPS values to Tuya devices 5 | 6 | You could also use set_value(dps_index,value) but would need to do that for each DPS value. 7 | To send it in one packet, you build the payload yourself and send it using something simliar 8 | to this example. 9 | 10 | Note: Some devices will not accept multiple commands and require you to send two separate commands. 11 | My Gosund dimmer switch is one of those and requires that I send two commands, 12 | one for '1' for on/off and one for '3' for the dimmer. 13 | 14 | Author: Jason A. Cox 15 | For more information see https://github.com/jasonacox/tinytuya 16 | 17 | """ 18 | import tinytuya 19 | 20 | # Connect to the device - replace with real values 21 | d=tinytuya.OutletDevice(DEVICEID, DEVICEIP, DEVICEKEY) 22 | d.set_version(3.3) 23 | 24 | # Generate the payload to send - add all the DPS values you want to change here 25 | payload=d.generate_payload(tinytuya.CONTROL, {'1': True, '2': 50}) 26 | 27 | # Optionally you can set separate gwId, devId and uid values 28 | # payload=d.generate_payload(tinytuya.CONTROL, data={'1': True, '2': 50}, gwId=DEVICEID, devId=DEVICEID, uid=DEVICEID) 29 | 30 | # Send the payload to the device 31 | d._send_receive(payload) -------------------------------------------------------------------------------- /tools/README.md: -------------------------------------------------------------------------------- 1 | # TinyTuya Tools 2 | 3 | ## Packet Capture Parser 4 | 5 | A program to read *.pcap files and decrypt the Tuya device traffic. It requires the dpkt module for PCAP parsing. 6 | 7 | Written by uzlonewolf (https://github.com/uzlonewolf) 8 | 9 | ### Setup 10 | 11 | ```bash 12 | # Install required python modules 13 | pip install dpkt 14 | 15 | # Test and display Help 16 | python3 pcap_parse.py -h 17 | ``` 18 | 19 | ### Usage 20 | 21 | ``` 22 | usage: pcap_parse.py [-h] [-z] [-s] -d devices.json INFILE.pcap [INFILE.pcap ...] 23 | 24 | Reads PCAP files created by tcpdump and prints the traffic to/from Tuya devices. Local keys 25 | are loaded from devices.json. 26 | 27 | positional arguments: 28 | INFILE.pcap Input file(s) to parse 29 | 30 | options: 31 | -h, --help show this help message and exit 32 | -z, --hide-zero-len Hide 0-length heartbeat packets 33 | -s, --sortable Output data in a way which is sortable by device ID 34 | -d devices.json, devices.json file to read local keys from 35 | ``` 36 | 37 | ### Example Usage 38 | 39 | ```bash 40 | # Capture local traffic - use control-C to end capture 41 | sudo tcpdump -i en0 -w trace.pcap 42 | ^C 43 | 44 | # Parse pcap file - make sure to specify location of devices.json 45 | python3 pcap_parse.py -d ../devices.json trace.pcap 46 | 47 | # Display output sorted 48 | python3 pcap_parse.py -s -d ../devices.json trace.pcap | sort 49 | ``` 50 | -------------------------------------------------------------------------------- /examples/cloud_rgb_bulb.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Example 2 | # -*- coding: utf-8 -*- 3 | """ 4 | TinyTuya - Tuya Cloud Functions 5 | 6 | This example uses the Tinytuya Cloud class and functions 7 | to access the Tuya Cloud to control an RGB Smart Bulb 8 | 9 | Author: Jason A. Cox 10 | For more information see https://github.com/jasonacox/tinytuya 11 | 12 | """ 13 | import tinytuya 14 | import colorsys 15 | import time 16 | 17 | # Set these values for your device 18 | id = DEVICEID 19 | cmd_code = 'colour_data_v2' # look at c.getstatus(id) to see what code should be used 20 | 21 | # Connect to Tuya Cloud - uses tinytuya.json 22 | c = tinytuya.Cloud() 23 | 24 | # Function to set color via RGB values - Bulb type B 25 | def set_color(rgb): 26 | hsv = colorsys.rgb_to_hsv(rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0) 27 | commands = { 28 | 'commands': [{ 29 | 'code': cmd_code, 30 | 'value': { 31 | "h": int(hsv[0] * 360), 32 | "s": int(hsv[1] * 1000), 33 | "v": int(hsv[2] * 1000) 34 | } 35 | }] 36 | } 37 | c.sendcommand(id, commands) 38 | 39 | # Rainbow values 40 | rainbow = {"red": (255, 0, 0), "orange": (255, 127, 0), "yellow": (255, 200, 0), 41 | "green": (0, 255, 0), "blue": (0, 0, 255), "indigo": (46, 43, 95), 42 | "violet": (139, 0, 255)} 43 | 44 | # Rotate through the rainbow 45 | for color in rainbow: 46 | print("Changing color to %s" % color) 47 | set_color(rainbow[color]) 48 | time.sleep(5) 49 | -------------------------------------------------------------------------------- /tinytuya/core/header.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Module 2 | # -*- coding: utf-8 -*- 3 | 4 | from . import command_types as CT 5 | 6 | # Protocol Versions and Headers 7 | PROTOCOL_VERSION_BYTES_31 = b"3.1" 8 | PROTOCOL_VERSION_BYTES_33 = b"3.3" 9 | PROTOCOL_VERSION_BYTES_34 = b"3.4" 10 | PROTOCOL_VERSION_BYTES_35 = b"3.5" 11 | PROTOCOL_3x_HEADER = 12 * b"\x00" 12 | PROTOCOL_33_HEADER = PROTOCOL_VERSION_BYTES_33 + PROTOCOL_3x_HEADER 13 | PROTOCOL_34_HEADER = PROTOCOL_VERSION_BYTES_34 + PROTOCOL_3x_HEADER 14 | PROTOCOL_35_HEADER = PROTOCOL_VERSION_BYTES_35 + PROTOCOL_3x_HEADER 15 | MESSAGE_HEADER_FMT = MESSAGE_HEADER_FMT_55AA = ">4I" # 4*uint32: prefix, seqno, cmd, length [, retcode] 16 | MESSAGE_HEADER_FMT_6699 = ">IHIII" # 4*uint32: prefix, unknown, seqno, cmd, length 17 | MESSAGE_RETCODE_FMT = ">I" # retcode for received messages 18 | MESSAGE_END_FMT = MESSAGE_END_FMT_55AA = ">2I" # 2*uint32: crc, suffix 19 | MESSAGE_END_FMT_HMAC = ">32sI" # 32s:hmac, uint32:suffix 20 | MESSAGE_END_FMT_6699 = ">16sI" # 16s:tag, suffix 21 | PREFIX_VALUE = PREFIX_55AA_VALUE = 0x000055AA 22 | PREFIX_BIN = PREFIX_55AA_BIN = b"\x00\x00U\xaa" 23 | SUFFIX_VALUE = SUFFIX_55AA_VALUE = 0x0000AA55 24 | SUFFIX_BIN = SUFFIX_55AA_BIN = b"\x00\x00\xaaU" 25 | PREFIX_6699_VALUE = 0x00006699 26 | PREFIX_6699_BIN = b"\x00\x00\x66\x99" 27 | SUFFIX_6699_VALUE = 0x00009966 28 | SUFFIX_6699_BIN = b"\x00\x00\x99\x66" 29 | 30 | NO_PROTOCOL_HEADER_CMDS = [CT.DP_QUERY, CT.DP_QUERY_NEW, CT.UPDATEDPS, CT.HEART_BEAT, CT.SESS_KEY_NEG_START, CT.SESS_KEY_NEG_RESP, CT.SESS_KEY_NEG_FINISH, CT.LAN_EXT_STREAM ] 31 | -------------------------------------------------------------------------------- /tinytuya/core/udp_helper.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Module 2 | # -*- coding: utf-8 -*- 3 | from hashlib import md5 4 | 5 | from .crypto_helper import AESCipher 6 | from . import header as H 7 | from .message_helper import parse_header, unpack_message 8 | 9 | 10 | def encrypt(msg, key): 11 | return AESCipher( key ).encrypt( msg, use_base64=False, pad=True ) 12 | 13 | def decrypt(msg, key): 14 | return AESCipher( key ).decrypt( msg, use_base64=False, decode_text=True ) 15 | 16 | #def decrypt_gcm(msg, key): 17 | # nonce = msg[:12] 18 | # return AES.new(key, AES.MODE_GCM, nonce=nonce).decrypt(msg[12:]).decode() 19 | 20 | # UDP packet payload decryption - credit to tuya-convert 21 | udpkey = md5(b"yGAdlopoPVldABfn").digest() 22 | 23 | def decrypt_udp(msg): 24 | try: 25 | header = parse_header(msg) 26 | except: 27 | header = None 28 | if not header: 29 | return decrypt(msg, udpkey) 30 | if header.prefix == H.PREFIX_55AA_VALUE: 31 | payload = unpack_message(msg).payload 32 | try: 33 | if payload[:1] == b'{' and payload[-1:] == b'}': 34 | return payload.decode() 35 | except: 36 | pass 37 | return decrypt(payload, udpkey) 38 | if header.prefix == H.PREFIX_6699_VALUE: 39 | unpacked = unpack_message(msg, hmac_key=udpkey, no_retcode=None) 40 | payload = unpacked.payload.decode() 41 | # app sometimes has extra bytes at the end 42 | while payload[-1] == chr(0): 43 | payload = payload[:-1] 44 | return payload 45 | return decrypt(msg, udpkey) 46 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Helpful Documentation 2 | 3 | ## Setup Instructions 4 | 5 | Below are some helpful tutorials on setting up and using TinyTuya to control devices. Please open an issue or PR to suggest others. 6 | 7 | * How to control smart power strips by Python by @yokoyama ([Japanese Tutorial](https://flogics.com/wp/ja/2022/02/control-smart-power-strips-by-python/)) 8 | * Smart Tuya Cloud integration with Raspberry PLC with Python by industrialshields (English) [PDF Instructions](https://www.industrialshields.com/web/content/276669?access_token=1f474610-27d8-4741-bea0-483bfe2abc76&unique=b032e5ee87b9c50009ef98c8b58a2d9c1081779d&download=true) https://www.industrialshields.com/blog/raspberry-pi-for-industry-26/post/home-automation-with-tuya-and-raspberry-pi-plc-307 9 | * Controlling lightbulb using Python and tinytuya library by Patryk Jakubiak - [YouTube Video](https://www.youtube.com/watch?v=d6ZUfLQeKTg) 10 | * Visualizing Local Electric Grid Pollution with a $7 Smart Lightbulb by Ben Bogart - [Tutorial](https://towardsdatascience.com/visualizing-local-electric-grid-pollution-with-a-7-smart-lightbulb-2cf16abe5f4e) 11 | * “Hacking an IoT Home”: New opportunities for cyber security education combining remote learning with cyber-physical systems by Phil Legg, Thomas Higgs, Pennie Spruhan, Jonathan White and Ian Johnson, 12 | Computer Science Research Centre, University of the West of England, Bristol, UK - [PDF](https://uwe-repository.worktribe.com/OutputFile/7337415) 13 | * HOW TO - Get All Local Tuya Keys (ALL KEYS, SIMPLE, NO SOLDERING) by Mark Watt Tech - [YouTube Video](https://www.youtube.com/watch?v=YKvGYXw-_cE) -------------------------------------------------------------------------------- /examples/zigbee_gateway.py: -------------------------------------------------------------------------------- 1 | import tinytuya 2 | import time 3 | 4 | # Zigbee Gateway support uses a parent/child model where a parent gateway device is 5 | # connected and then one or more children are added. 6 | 7 | # configure the parent device 8 | # address=None will cause it to search the network for the device 9 | gw = tinytuya.Device( 'eb...4', address=None, local_key='aabbccddeeffgghh', persist=True, version=3.3 ) 10 | 11 | print( 'GW IP found:', gw.address ) 12 | 13 | # configure one or more children. Every dev_id must be unique! 14 | # cid is the "node_id" from devices.json 15 | # node_id can be used as an alias for cid 16 | zigbee1 = tinytuya.OutletDevice( 'eb14...w', cid='0011223344556601', parent=gw ) 17 | zigbee2 = tinytuya.OutletDevice( 'eb04...l', cid='0011223344556689', parent=gw ) 18 | 19 | print(zigbee1.status()) 20 | print(zigbee2.status()) 21 | 22 | print(" > Begin Monitor Loop <") 23 | pingtime = time.time() + 9 24 | 25 | while(True): 26 | if( pingtime <= time.time() ): 27 | payload = gw.generate_payload(tinytuya.HEART_BEAT) 28 | gw.send(payload) 29 | pingtime = time.time() + 9 30 | 31 | # receive from the gateway object to get updates for all sub-devices 32 | print('recv:') 33 | data = gw.receive() 34 | print( data ) 35 | 36 | # data['device'] contains a reference to the device object 37 | if data and 'device' in data and data['device'] == zigbee1: 38 | print('toggling device state') 39 | time.sleep(1) 40 | if data['dps']['1']: 41 | data['device'].turn_off(nowait=True) 42 | else: 43 | data['device'].turn_on(nowait=True) 44 | -------------------------------------------------------------------------------- /DP_Mapping.md: -------------------------------------------------------------------------------- 1 | # Tuya Cloud - Changing the Control Instruction Mode 2 | 3 | DPS to Name mappings are now downloaded with devices.json starting with TinyTuya v1.12.8. If this DPS mapping is not correct then you will need to change the Control Instruction Mode to DP Instruction Mode. 4 | 5 | ## How to get the full DPS mapping from Tuya Cloud. 6 | 7 | This how-to will show you how to activate “DP Instruction” mode for your Tuya devices when using Tuya Cloud to pull data. This will result in getting the full list of DPS values and their properties for your devices. 8 | 9 | ### Step 1 - Log in to your account on [iot.tuya.com](iot.tuya.com) 10 | 11 | ### Step 2 - Navigate to "Cloud" -> "Development" then select your project. 12 | 13 | ### Step 3 - Select "Devices" tab. 14 | 15 | image 16 | 17 | ### Step 4- Select the device types and click the the "pencil icon" to edit. 18 | 19 | image 20 | 21 | ### Step 5 - Select the "DP Instruction" box and "Save Configuration" 22 | 23 | image 24 | 25 | There doesn't appear to be a way to globally set "DP Instruction" for all device types. You will need to select each device type and repeat the above step. 26 | 27 | ### Step 6 - Use TinyTuya to access the full set of DPS 28 | 29 | After 12-24 hours, you should now be able to poll the Tuya Cloud using TinyTuya to get the full list of DPS properties and values. Simply delete `devices.json` and re-run the Wizard. 30 | -------------------------------------------------------------------------------- /tinytuya/core/command_types.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Module 2 | # -*- coding: utf-8 -*- 3 | 4 | # Tuya Command Types 5 | # Reference: https://github.com/tuya/tuya-iotos-embeded-sdk-wifi-ble-bk7231n/blob/master/sdk/include/lan_protocol.h 6 | AP_CONFIG = 1 # FRM_TP_CFG_WF # only used for ap 3.0 network config 7 | ACTIVE = 2 # FRM_TP_ACTV (discard) # WORK_MODE_CMD 8 | SESS_KEY_NEG_START = 3 # FRM_SECURITY_TYPE3 # negotiate session key 9 | SESS_KEY_NEG_RESP = 4 # FRM_SECURITY_TYPE4 # negotiate session key response 10 | SESS_KEY_NEG_FINISH = 5 # FRM_SECURITY_TYPE5 # finalize session key negotiation 11 | UNBIND = 6 # FRM_TP_UNBIND_DEV # DATA_QUERT_CMD - issue command 12 | CONTROL = 7 # FRM_TP_CMD # STATE_UPLOAD_CMD 13 | STATUS = 8 # FRM_TP_STAT_REPORT # STATE_QUERY_CMD 14 | HEART_BEAT = 9 # FRM_TP_HB 15 | DP_QUERY = 0x0a # 10 # FRM_QUERY_STAT # UPDATE_START_CMD - get data points 16 | QUERY_WIFI = 0x0b # 11 # FRM_SSID_QUERY (discard) # UPDATE_TRANS_CMD 17 | TOKEN_BIND = 0x0c # 12 # FRM_USER_BIND_REQ # GET_ONLINE_TIME_CMD - system time (GMT) 18 | CONTROL_NEW = 0x0d # 13 # FRM_TP_NEW_CMD # FACTORY_MODE_CMD 19 | ENABLE_WIFI = 0x0e # 14 # FRM_ADD_SUB_DEV_CMD # WIFI_TEST_CMD 20 | WIFI_INFO = 0x0f # 15 # FRM_CFG_WIFI_INFO 21 | DP_QUERY_NEW = 0x10 # 16 # FRM_QUERY_STAT_NEW 22 | SCENE_EXECUTE = 0x11 # 17 # FRM_SCENE_EXEC 23 | UPDATEDPS = 0x12 # 18 # FRM_LAN_QUERY_DP # Request refresh of DPS 24 | UDP_NEW = 0x13 # 19 # FR_TYPE_ENCRYPTION 25 | AP_CONFIG_NEW = 0x14 # 20 # FRM_AP_CFG_WF_V40 26 | BOARDCAST_LPV34 = 0x23 # 35 # FR_TYPE_BOARDCAST_LPV34 27 | REQ_DEVINFO = 0x25 # broadcast to port 7000 to get v3.5 devices to send their info 28 | LAN_EXT_STREAM = 0x40 # 64 # FRM_LAN_EXT_STREAM 29 | -------------------------------------------------------------------------------- /tinytuya/core/error_helper.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Module 2 | # -*- coding: utf-8 -*- 3 | 4 | import json 5 | import logging 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | 10 | # TinyTuya Error Response Codes 11 | ERR_JSON = 900 12 | ERR_CONNECT = 901 13 | ERR_TIMEOUT = 902 14 | ERR_RANGE = 903 15 | ERR_PAYLOAD = 904 16 | ERR_OFFLINE = 905 17 | ERR_STATE = 906 18 | ERR_FUNCTION = 907 19 | ERR_DEVTYPE = 908 20 | ERR_CLOUDKEY = 909 21 | ERR_CLOUDRESP = 910 22 | ERR_CLOUDTOKEN = 911 23 | ERR_PARAMS = 912 24 | ERR_CLOUD = 913 25 | ERR_KEY_OR_VER = 914 26 | 27 | error_codes = { 28 | ERR_JSON: "Invalid JSON Response from Device", 29 | ERR_CONNECT: "Network Error: Unable to Connect", 30 | ERR_TIMEOUT: "Timeout Waiting for Device", 31 | ERR_RANGE: "Specified Value Out of Range", 32 | ERR_PAYLOAD: "Unexpected Payload from Device", 33 | ERR_OFFLINE: "Network Error: Device Unreachable", 34 | ERR_STATE: "Device in Unknown State", 35 | ERR_FUNCTION: "Function Not Supported by Device", 36 | ERR_DEVTYPE: "Device22 Detected: Retry Command", 37 | ERR_CLOUDKEY: "Missing Tuya Cloud Key and Secret", 38 | ERR_CLOUDRESP: "Invalid JSON Response from Cloud", 39 | ERR_CLOUDTOKEN: "Unable to Get Cloud Token", 40 | ERR_PARAMS: "Missing Function Parameters", 41 | ERR_CLOUD: "Error Response from Tuya Cloud", 42 | ERR_KEY_OR_VER: "Check device key or version", 43 | None: "Unknown Error", 44 | } 45 | 46 | 47 | def error_json(number=None, payload=None): 48 | """Return error details in JSON""" 49 | try: 50 | spayload = json.dumps(payload) 51 | # spayload = payload.replace('\"','').replace('\'','') 52 | except: 53 | spayload = '""' 54 | 55 | vals = (error_codes[number], str(number), spayload) 56 | log.debug("ERROR %s - %s - payload: %s", *vals) 57 | 58 | return json.loads('{ "Error":"%s", "Err":"%s", "Payload":%s }' % vals) 59 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | from pkg_resources import DistributionNotFound, get_distribution 3 | 4 | from tinytuya import __version__ 5 | 6 | with open("README.md", "r") as fh: 7 | long_description = fh.read() 8 | 9 | INSTALL_REQUIRES = [ 10 | 'requests', # Used for Setup Wizard - Tuya IoT Platform calls 11 | 'colorama', # Makes ANSI escape character sequences work under MS Windows. 12 | #'netifaces', # Used for device discovery, mainly required on multi-interface machines 13 | ] 14 | 15 | CHOOSE_CRYPTO_LIB = [ 16 | 'cryptography', # pyca/cryptography - https://cryptography.io/en/latest/ 17 | 'pycryptodome', # PyCryptodome - https://pycryptodome.readthedocs.io/en/latest/ 18 | 'pyaes', # pyaes - https://github.com/ricmoo/pyaes 19 | 'pycrypto', # PyCrypto - https://www.pycrypto.org/ 20 | ] 21 | 22 | pref_lib = CHOOSE_CRYPTO_LIB[0] 23 | for cryptolib in CHOOSE_CRYPTO_LIB: 24 | try: 25 | get_distribution(cryptolib) 26 | pref_lib = cryptolib 27 | break 28 | except DistributionNotFound: 29 | pass 30 | 31 | INSTALL_REQUIRES.append( pref_lib ) 32 | 33 | setuptools.setup( 34 | name="tinytuya", 35 | version=__version__, 36 | author="Jason Cox", 37 | author_email="jason@jasonacox.com", 38 | description="Python module to interface with Tuya WiFi smart devices", 39 | long_description=long_description, 40 | long_description_content_type="text/markdown", 41 | url='https://github.com/jasonacox/tinytuya', 42 | packages=setuptools.find_packages(exclude=("sandbox",)), 43 | install_requires=INSTALL_REQUIRES, 44 | entry_points={"console_scripts": ["tinytuya=tinytuya.__main__:dummy"]}, 45 | classifiers=[ 46 | "Programming Language :: Python :: 3", 47 | "License :: OSI Approved :: MIT License", 48 | "Operating System :: OS Independent", 49 | ], 50 | ) 51 | -------------------------------------------------------------------------------- /examples/snapshot.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Example 2 | # -*- coding: utf-8 -*- 3 | """ 4 | TinyTuya - Example script that uses the snapshot.json to manage Tuya Devices 5 | 6 | Author: Jason A. Cox 7 | For more information see https://github.com/jasonacox/tinytuya 8 | """ 9 | 10 | import tinytuya 11 | import json 12 | import time 13 | 14 | with open('snapshot.json') as json_file: 15 | data = json.load(json_file) 16 | 17 | # Print a table with all devices 18 | print("%-25s %-24s %-16s %-17s %-5s" % ("Name","ID", "IP","Key","Version")) 19 | for item in data["devices"]: 20 | print("%-25.25s %-24s %-16s %-17s %-5s" % ( 21 | item["name"], 22 | item["id"], 23 | item["ip"], 24 | item["key"], 25 | item["ver"])) 26 | 27 | # Print status of everything 28 | for item in data["devices"]: 29 | print("\nDevice: %s" % item["name"]) 30 | if item["ip"] == "": 31 | print("No IP Address - Skipping") 32 | else: 33 | d = tinytuya.OutletDevice(item["id"], item["ip"], item["key"]) 34 | d.set_version(float(item["ver"])) 35 | status = d.status() 36 | print(status) 37 | 38 | # Turn on a device by name 39 | def turn_on(name): 40 | # find the right item that matches name 41 | for item in data["devices"]: 42 | if item["name"] == name: 43 | break 44 | print("\nTurning On: %s" % item["name"]) 45 | d = tinytuya.OutletDevice(item["id"], item["ip"], item["key"]) 46 | d.set_version(float(item["ver"])) 47 | d.set_status(True) 48 | 49 | # Turn off a device by name 50 | def turn_off(name): 51 | # find the right item that matches name 52 | for item in data["devices"]: 53 | if item["name"] == name: 54 | break 55 | print("\nTurning Off: %s" % item["name"]) 56 | d = tinytuya.OutletDevice(item["id"], item["ip"], item["key"]) 57 | d.set_version(float(item["ver"])) 58 | d.set_status(False) 59 | 60 | # Test it 61 | turn_off('Dining Room') 62 | time.sleep(2) 63 | turn_on('Dining Room') 64 | 65 | -------------------------------------------------------------------------------- /server/web/tinytuya.css: -------------------------------------------------------------------------------- 1 | 2 | body { 3 | font-family: sans-serif; 4 | font-size: small; 5 | } 6 | .title { 7 | /* Center the title at top with blue background white text */ 8 | background-color: blue; 9 | color: white; 10 | text-align: center; 11 | padding: 5px; 12 | font-size: large; 13 | font-weight: bold; 14 | } 15 | .address, .onlinetext { 16 | color: green; 17 | } 18 | .id { 19 | color: blue; 20 | } 21 | .offlinetext { 22 | color: red; 23 | } 24 | .version { 25 | color: rgb(195, 172, 89); 26 | } 27 | table { 28 | border-collapse: collapse; 29 | } 30 | tr { 31 | border: 1px solid black; 32 | } 33 | td, th { 34 | text-align: left; 35 | padding-left: 5px; 36 | padding-right: 5px; 37 | } 38 | tbody tr:nth-child(even) { 39 | background-color: #eeeeee; 40 | } 41 | thead { 42 | background-color: gray; 43 | color: white; 44 | text-align: left; 45 | padding: auto; 46 | } 47 | a:link, a:visited { 48 | color: black; 49 | text-decoration: underline; 50 | } 51 | a:hover, a:active { 52 | background-color: black; 53 | color: white; 54 | } 55 | .lcolumn { 56 | flex: 60%; 57 | padding: 5px; 58 | } 59 | .rcolumn { 60 | flex: 40%; 61 | padding: 5px; 62 | } 63 | .row { 64 | /* Full window height minus title and button */ 65 | height: calc(100vh - 100px); 66 | /* Allow scroll */ 67 | overflow: auto; 68 | display: flex; 69 | } 70 | /* Clear floats after the columns */ 71 | .row:after { 72 | content: ""; 73 | display: table; 74 | clear: both; 75 | } 76 | .value { 77 | color: rgb(73, 73, 73); 78 | } 79 | .button { 80 | /* put button a bottom of the page */ 81 | position: fixed; 82 | bottom: 0; 83 | left: 0; 84 | height: 40px; 85 | width: 100%; 86 | /* make it look like a button */ 87 | background-color: #4CAF50; 88 | border: none; 89 | color: white; 90 | padding: 5px 5px; 91 | text-align: center; 92 | text-decoration: none; 93 | display: inline-block; 94 | font-size: 14px; 95 | margin: 4px 2px; 96 | cursor: pointer; 97 | } 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | upload.sh 106 | 107 | # testing 108 | test.sh 109 | .vscode/ 110 | sandbox/ 111 | .DS_Store 112 | .vscode/settings.json 113 | devices.json 114 | tinytuya.json 115 | snapshot.json 116 | tuya-raw.json 117 | server/testproxy.sh 118 | server/uploadtest.sh 119 | devices.json.off 120 | mappings.json 121 | 122 | # packet captures 123 | *.pcap 124 | *.pcapng 125 | 126 | # backup files 127 | *.bak 128 | *~ 129 | 130 | # test scripts 131 | server/r 132 | server/tinytuya 133 | -------------------------------------------------------------------------------- /examples/async_send_receive.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Example 2 | # -*- coding: utf-8 -*- 3 | """ 4 | TinyTuya - Example showing async persistent connection to device with 5 | continual loop watching for device updates. 6 | 7 | Author: Jason A. Cox 8 | For more information see https://github.com/jasonacox/tinytuya 9 | 10 | """ 11 | import time 12 | import tinytuya 13 | 14 | # tinytuya.set_debug(True) 15 | 16 | d = tinytuya.OutletDevice('DEVICEID', 'DEVICEIP', 'DEVICEKEY') 17 | d.set_version(3.3) 18 | d.set_socketPersistent(True) 19 | 20 | # Devices will close the connection if they do not receve data every 30 seconds 21 | # Sending heartbeat packets every 9 seconds gives some wiggle room for lost packets or loop lag 22 | PING_TIME = 9 23 | 24 | # Option - also poll 25 | POLL_TIME = 60 26 | 27 | print(" > Send Request for Status < ") 28 | d.status(nowait=True) 29 | 30 | print(" > Begin Monitor Loop <") 31 | pingtime = time.time() + PING_TIME 32 | polltime = time.time() + POLL_TIME 33 | while(True): 34 | # See if any data is available 35 | data = d.receive() 36 | if data: 37 | print('Received Payload: %r' % data) 38 | 39 | if( pingtime <= time.time() ): 40 | pingtime = time.time() + PING_TIME 41 | # Send keep-alive heartbeat 42 | print(" > Send Heartbeat Ping < ") 43 | d.heartbeat(nowait=True) 44 | 45 | # Option - Poll for status 46 | if( polltime <= time.time() ): 47 | polltime = time.time() + POLL_TIME 48 | 49 | # Option - Some plugs require an UPDATEDPS command to update their power data points 50 | if False: 51 | print(" > Send DPS Update Request < ") 52 | 53 | # # Some Tuya devices require a list of DPs to update 54 | # payload = d.generate_payload(tinytuya.UPDATEDPS,['18','19','20']) 55 | # data = d.send(payload) 56 | # print('Received Payload: %r' % data) 57 | 58 | # # Other devices will not accept the DPS index values for UPDATEDPS - try: 59 | # payload = d.generate_payload(tinytuya.UPDATEDPS) 60 | # data = d.send(payload) 61 | # print('Received Payload: %r' % data) 62 | 63 | print(" > Send Request for Status < ") 64 | data = d.status() 65 | print('Received Payload: %r' % data) 66 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # TinyTuya - Examples 2 | 3 | Code examples using `tinytuya` module to control various Tuya devices. 4 | 5 | ## Read Tuya Device Status 6 | 7 | [getstatus.py](getstatus.py) - This script will read the status of a Tuya device. 8 | 9 | ## Smart Bulb (RGB) 10 | 11 | [bulb.py](bulb.py) - This script tests controlling Smart Bulb with RGB capabilities. 12 | 13 | [bulb-scenes.py](bulb-scenes.py) - This script tests out setting Scenes for the smart bulb 14 | 15 | Tested devices: Peteme Smart Light Bulbs, Wi-Fi - [link](https://www.amazon.com/gp/product/B07MKDLV1V/) 16 | 17 | [galaxy_projector.py](galaxy_projector.py) This script tests controlling a Galaxy Projector from [galaxylamps.co](https://eu.galaxylamps.co/collections/all/products/galaxy-projector) 18 | 19 | ## Continuous Monitor 20 | 21 | [monitor.py](monitor.py) - This script uses a loop to listen to a Tuya device for any state changes. 22 | 23 | ## Async Send and Receive 24 | 25 | [async_send_receive.py](async_send_receive.py) - This demonstrates how you can make a persistent connection to a Tuya device, send commands and monitor for responses in an async way. 26 | 27 | ## Send Raw DPS Values 28 | 29 | [send_raw_dps.py](send_raw_dps.py) - This script show how to send and set raw DPS values on a Tuya device. 30 | 31 | ## Scan all Devices 32 | 33 | [devices.py](devices.py) - Poll status of all devices in `devices.json`. 34 | 35 | ## Use snapshot.json to Manage Devices 36 | 37 | [snapshot.py](snapshot.py) - Example of using `snapshot.json` to manage Tuya Devices 38 | 39 | ```python 40 | # Load in snapshot.py and control by name 41 | turn_off('Dining Room') 42 | time.sleep(2) 43 | turn_on('Dining Room') 44 | ``` 45 | 46 | ## Tuya Cloud API Examples 47 | 48 | [cloud.py](cloud.py) - Example that uses the Tinytuya `Cloud` class and functions to access the Tuya Cloud API to pull device information and control the device via the cloud. 49 | 50 | ## Multi-Threaded Example 51 | 52 | [threading.py](threading.py) - Example that uses python threading to connect to multiple devices and listen for updates. 53 | 54 | ## Multiple Device Select Example 55 | 56 | [multi-select.py](multi-select.py) - Example that uses python select() to connect to multiple devices and listen for updates simultaneously. By using select(), the program avoids having to create separate threads for each device and can efficiently handle multiple device connections in a single loop. 57 | -------------------------------------------------------------------------------- /examples/cloud.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Example 2 | # -*- coding: utf-8 -*- 3 | """ 4 | TinyTuya - Tuya Cloud Functions 5 | 6 | This examples uses the Tinytuya Cloud class and functions 7 | to access the Tuya Cloud to pull device information and 8 | control the device via the cloud. 9 | 10 | Author: Jason A. Cox 11 | For more information see https://github.com/jasonacox/tinytuya 12 | 13 | """ 14 | import tinytuya 15 | 16 | # Turn on Debug Mode 17 | tinytuya.set_debug(True) 18 | 19 | # You can have tinytuya pull the API credentials 20 | # from the tinytuya.json file created by the wizard 21 | # c = tinytuya.Cloud() 22 | # Alternatively you can specify those values here: 23 | # Connect to Tuya Cloud 24 | c = tinytuya.Cloud( 25 | apiRegion="us", 26 | apiKey="xxxxxxxxxxxxxxxxxxxx", 27 | apiSecret="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 28 | apiDeviceID="xxxxxxxxxxxxxxxxxxID") 29 | 30 | # Display list of devices 31 | devices = c.getdevices() 32 | print("Device List: %r" % devices) 33 | 34 | # Select a Device ID to Test 35 | id = "xxxxxxxxxxxxxxxxxxID" 36 | 37 | # Display Properties of Device 38 | result = c.getproperties(id) 39 | print("Properties of device:\n", result) 40 | 41 | # Display Functions of Device 42 | result = c.getfunctions(id) 43 | print("Functions of device:\n", result) 44 | 45 | # Display DPS IDs of Device 46 | result = c.getdps(id) 47 | print("DPS IDs of device:\n", result) 48 | 49 | # Display Status of Device 50 | result = c.getstatus(id) 51 | print("Status of device:\n", result) 52 | 53 | # Send Command - This example assumes a basic switch 54 | commands = { 55 | 'commands': [{ 56 | 'code': 'switch_1', 57 | 'value': True 58 | }, { 59 | 'code': 'countdown_1', 60 | 'value': 0 61 | }] 62 | } 63 | print("Sending command...") 64 | result = c.sendcommand(id,commands) 65 | print("Results\n:", result) 66 | 67 | # Get device logs 68 | # Note: the returned timestamps are unixtime*1000 69 | # event_id 7 (data report) will probably be the most useful 70 | # More information can be found at https://developer.tuya.com/en/docs/cloud/cbea13f274?id=Kalmcohrembze 71 | 72 | # Get device logs from the last day 73 | result = c.getdevicelog(id) 74 | print("Device logs:\n", result) 75 | 76 | # Get device logs from 7 days ago through 5 days ago (2 days worth) 77 | #result = c.getdevicelog(id, start=-7, end=-5) 78 | #print("Device logs:\n", result) 79 | 80 | # Get device logs for one day ending an hour ago 81 | #result = c.getdevicelog(id, end=time.time() - 3600) 82 | #print("Device logs:\n", result) 83 | -------------------------------------------------------------------------------- /examples/cloud_ir.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Example 2 | # -*- coding: utf-8 -*- 3 | """ 4 | TinyTuya - Tuya Cloud IR Functions 5 | 6 | This example uses the Tinytuya Cloud class and functions 7 | to send IR blaster commands 8 | 9 | Author: uzlonewolf 10 | For more information see https://github.com/jasonacox/tinytuya 11 | 12 | """ 13 | import tinytuya 14 | import colorsys 15 | import time 16 | import json 17 | 18 | #tinytuya.set_debug() 19 | 20 | # Set this to the actual blaster device, not a virtual remote 21 | device_id = DEVICEID 22 | 23 | # Connect to Tuya Cloud - uses tinytuya.json 24 | c = tinytuya.Cloud() 25 | 26 | 27 | 28 | 29 | # Raw IR commands can be sent directly 30 | ir_cmd = { 31 | "control":"send_ir", 32 | "head":"010ed20000000000040015004000ad0730", 33 | "key1":"002$$0020E0E0E01F@%", 34 | "type":0, 35 | "delay":300 36 | } 37 | 38 | cloud_cmd = { 39 | "commands": [ 40 | { 41 | "code": "ir_send", 42 | "value": json.dumps(ir_cmd) 43 | }, 44 | ] 45 | } 46 | 47 | print('Send Raw result:') 48 | res = c.sendcommand(device_id, cloud_cmd) 49 | print( json.dumps(res, indent=2) ) 50 | 51 | 52 | 53 | 54 | # Keys from a virtual remote can also be sent 55 | # 56 | # See https://developer.tuya.com/en/docs/cloud/ir-control-hub-open-service?id=Kb3oe2mk8ya72 57 | # for API documentation 58 | 59 | 60 | # First, get a listing of all programmed remotes 61 | print('List of remotes:') 62 | remote_list = c.cloudrequest( '/v2.0/infrareds/' + device_id + '/remotes' ) 63 | print( json.dumps(remote_list, indent=2) ) 64 | 65 | # Next, get a list of keys for a remote using remote_id from the list returned by the previous command 66 | print('List of keys on 1st remote:') 67 | remote_id = remote_list['result'][0]['remote_id'] # Grab the first remote for this example 68 | remote_key_list = c.cloudrequest( '/v2.0/infrareds/%s/remotes/%s/keys' % (device_id, remote_id) ) 69 | print( json.dumps(remote_key_list, indent=2) ) 70 | 71 | # Finally, send the 'Power' key 72 | post_data = { 73 | "key": "OK", #"Power", 74 | "category_id": remote_key_list['result']['category_id'], 75 | "remote_index": remote_key_list['result']['remote_index'] 76 | } 77 | print('Send key result:') 78 | res = c.cloudrequest( '/v2.0/infrareds/%s/remotes/%s/command' % (device_id, remote_id), post=post_data ) 79 | print( json.dumps(res, indent=2) ) 80 | 81 | 82 | 83 | # The actual value sent by the above key can be found by checking the device logs 84 | print('Device logs:') 85 | logs = c.getdevicelog(device_id, evtype='5', size=3, max_fetches=1) 86 | print( json.dumps(logs, indent=2) ) 87 | -------------------------------------------------------------------------------- /examples/bulb-music.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Example 2 | # -*- coding: utf-8 -*- 3 | """ 4 | TinyTuya - Smart Bulb RGB Music Test 5 | 6 | Author: Jason A. Cox 7 | For more information see https://github.com/jasonacox/tinytuya 8 | 9 | """ 10 | import tinytuya 11 | import time 12 | import random 13 | import os 14 | 15 | #tinytuya.set_debug() 16 | 17 | DEVICEID = "01234567891234567890" 18 | DEVICEIP = "Auto" # Will try to discover the bulb on the network 19 | DEVICEKEY = "" # Leave blank to read from devices.json 20 | DEVICEVERS = 3.3 # Must be set correctly unless IP=Auto 21 | 22 | # Check for environmental variables and always use those if available 23 | DEVICEID = os.getenv("DEVICEID", DEVICEID) 24 | DEVICEIP = os.getenv("DEVICEIP", DEVICEIP) 25 | DEVICEKEY = os.getenv("DEVICEKEY", DEVICEKEY) 26 | DEVICEVERS = os.getenv("DEVICEVERS", DEVICEVERS) 27 | 28 | print("TinyTuya - Smart Bulb Music Test [%s]\n" % tinytuya.__version__) 29 | print('TESTING: Device %s at %s with key %s version %s' % 30 | (DEVICEID, DEVICEIP, DEVICEKEY, DEVICEVERS)) 31 | 32 | # Connect to Tuya BulbDevice 33 | d = tinytuya.BulbDevice(DEVICEID, address=DEVICEIP, local_key=DEVICEKEY, version=DEVICEVERS, persist=True) 34 | 35 | if (not DEVICEIP) or (DEVICEIP == 'Auto') or (not DEVICEKEY) or (not DEVICEVERS): 36 | print('Device %s found at %s with key %r version %s' % 37 | (d.id, d.address, d.local_key, d.version)) 38 | 39 | # Show status of device 40 | data = d.status() 41 | print('\nCurrent Status of Bulb: %r' % data) 42 | 43 | # Music Test 44 | print('Setting to Music') 45 | d.set_mode('music') 46 | data = d.status() 47 | 48 | d.set_socketPersistent( True ) 49 | 50 | # Devices respond with a command ACK, but do not send DP updates. 51 | # Setting the 2 options below causes it to wait for a response but 52 | # return immediately after an ACK. 53 | d.set_sendWait( None ) 54 | d.set_retry( False ) 55 | 56 | for x in range(100): 57 | # Value is 0 1111 2222 3333 4444 5555 58 | # see: https://developer.tuya.com/en/docs/iot/solarlight-function-definition?id=K9tp16f086d5h#title-10-DP27(8)%3A%20music 59 | red = random.randint(0,255) 60 | green = random.randint(0,255) 61 | blue = random.randint(0,255) 62 | 63 | if (x % 6 == 0): 64 | # extend every 6 beat 65 | d.set_music_colour( d.MUSIC_TRANSITION_FADE, red, green, blue ) 66 | time.sleep(2) 67 | else: 68 | # Jump! 69 | d.set_music_colour( d.MUSIC_TRANSITION_JUMP, red, green, blue ) 70 | time.sleep(0.1) # the bulbs seem to get upset if updates are faster than 0.1s (100ms) 71 | 72 | # Done 73 | print('\nDone') 74 | d.turn_off() 75 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '17 1 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'python' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /examples/bulb-scenes.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Example 2 | # -*- coding: utf-8 -*- 3 | """ 4 | TinyTuya - RGB SmartBulb - Scene Test for Bulbs 5 | 6 | Author: Jason A. Cox 7 | For more information see https://github.com/jasonacox/tinytuya 8 | 9 | """ 10 | import tinytuya 11 | import time 12 | import os 13 | 14 | 15 | DEVICEID = "01234567891234567890" 16 | DEVICEIP = "Auto" # Will try to discover the bulb on the network 17 | DEVICEKEY = "" # Leave blank to read from devices.json 18 | DEVICEVERS = 3.3 # Must be set correctly unless IP=Auto 19 | 20 | # Check for environmental variables and always use those if available 21 | DEVICEID = os.getenv("DEVICEID", DEVICEID) 22 | DEVICEIP = os.getenv("DEVICEIP", DEVICEIP) 23 | DEVICEKEY = os.getenv("DEVICEKEY", DEVICEKEY) 24 | DEVICEVERS = os.getenv("DEVICEVERS", DEVICEVERS) 25 | 26 | print("TinyTuya - Smart Bulb String Scenes Test [%s]\n" % tinytuya.__version__) 27 | print('TESTING: Device %s at %s with key %s version %s' % 28 | (DEVICEID, DEVICEIP, DEVICEKEY, DEVICEVERS)) 29 | 30 | # Connect to Tuya BulbDevice 31 | d = tinytuya.BulbDevice(DEVICEID, address=DEVICEIP, local_key=DEVICEKEY, version=DEVICEVERS, persist=True) 32 | 33 | if (not DEVICEIP) or (DEVICEIP == 'Auto') or (not DEVICEKEY) or (not DEVICEVERS): 34 | print('Device %s found at %s with key %r version %s' % 35 | (d.id, d.address, d.local_key, d.version)) 36 | 37 | # Show status of device 38 | data = d.status() 39 | print('\nCurrent Status of Bulb: %r' % data) 40 | 41 | # Set Mode to Scenes 42 | print('\nSetting bulb mode to Scenes') 43 | d.set_mode('scene') 44 | 45 | if d.bulb_has_capability(d.BULB_FEATURE_SCENE_DATA): 46 | print('\n String based scenes compatible smartbulb detected.') 47 | # Example: Color rotation 48 | print(' Switch to Scene 7 - Color Rotation') 49 | d.set_scene( 7, '464602000003e803e800000000464602007803e803e80000000046460200f003e803e800000000464602003d03e803e80000000046460200ae03e803e800000000464602011303e803e800000000') 50 | time.sleep(10) 51 | 52 | # Example: Read scene 53 | print(' Switch to Scene 1 - Reading Light') 54 | d.set_scene( 1, '0e0d0000000000000003e803e8') 55 | time.sleep(5) 56 | 57 | # You can pull the scene strings from your smartbulb by running the async_send_receive.py script 58 | # and using the SmartLife app to change between scenes. 59 | else: 60 | print('\n Your smartbulb does not appear to support string based scenes.') 61 | # Rotate through numeric scenes 62 | for n in range(1, 5): 63 | print(' Scene - %d' % n) 64 | d.set_scene(n) 65 | time.sleep(5) 66 | 67 | # Done 68 | print('\nDone') 69 | d.turn_off() 70 | -------------------------------------------------------------------------------- /examples/monitor.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Example 2 | # -*- coding: utf-8 -*- 3 | """ 4 | TinyTuya - Example script to monitor state changes with Tuya devices. 5 | 6 | Author: Jason A. Cox 7 | For more information see https://github.com/jasonacox/tinytuya 8 | 9 | """ 10 | import tinytuya 11 | import time 12 | 13 | # tinytuya.set_debug(True) 14 | 15 | # Setting the address to 'Auto' or None will trigger a scan which will auto-detect both the address and version, but this can take up to 8 seconds 16 | d = tinytuya.OutletDevice('DEVICEID', 'Auto', 'DEVICEKEY', persist=True) 17 | # If you know both the address and version then supplying them is a lot quicker 18 | # d = tinytuya.OutletDevice('DEVICEID', 'DEVICEIP', 'DEVICEKEY', version=DEVICEVERSION, persist=True) 19 | 20 | STATUS_TIMER = 30 21 | KEEPALIVE_TIMER = 12 22 | 23 | print(" > Send Request for Status < ") 24 | data = d.status() 25 | print('Initial Status: %r' % data) 26 | if data and 'Err' in data: 27 | print("Status request returned an error, is version %r and local key %r correct?" % (d.version, d.local_key)) 28 | 29 | print(" > Begin Monitor Loop <") 30 | heartbeat_time = time.time() + KEEPALIVE_TIMER 31 | status_time = None 32 | 33 | # Uncomment if you want the monitor to constantly request status - otherwise you 34 | # will only get updates when state changes 35 | #status_time = time.time() + STATUS_TIMER 36 | 37 | while(True): 38 | if status_time and time.time() >= status_time: 39 | # Uncomment if your device provides power monitoring data but it is not updating 40 | # Some devices require a UPDATEDPS command to force measurements of power. 41 | # print(" > Send DPS Update Request < ") 42 | # Most devices send power data on DPS indexes 18, 19 and 20 43 | # d.updatedps(['18','19','20'], nowait=True) 44 | # Some Tuya devices will not accept the DPS index values for UPDATEDPS - try: 45 | # payload = d.generate_payload(tinytuya.UPDATEDPS) 46 | # d.send(payload) 47 | 48 | # poll for status 49 | print(" > Send Request for Status < ") 50 | data = d.status() 51 | status_time = time.time() + STATUS_TIMER 52 | heartbeat_time = time.time() + KEEPALIVE_TIMER 53 | elif time.time() >= heartbeat_time: 54 | # send a keep-alive 55 | data = d.heartbeat(nowait=False) 56 | heartbeat_time = time.time() + KEEPALIVE_TIMER 57 | else: 58 | # no need to send anything, just listen for an asynchronous update 59 | data = d.receive() 60 | 61 | print('Received Payload: %r' % data) 62 | 63 | if data and 'Err' in data: 64 | print("Received error!") 65 | # rate limit retries so we don't hammer the device 66 | time.sleep(5) 67 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | TinyTuya test for OutletDevice 4 | 5 | Author: Jason A. Cox 6 | For more information see https://github.com/jasonacox/tinytuya 7 | """ 8 | import sys 9 | import os 10 | import time 11 | import tinytuya 12 | 13 | # Read command line options or set defaults 14 | if (len(sys.argv) < 2) and not (("PLUGID" in os.environ) or ("PLUGIP" in os.environ)): 15 | print("TinyTuya (Tuya Interface) [%s]"%tinytuya.__version__) 16 | print("Usage: %s \n" % sys.argv[0]) 17 | print(" Required: is the Device ID e.g. 01234567891234567890") 18 | print(" is the IP address of the smart plug e.g. 10.0.1.99") 19 | print(" Optional: is the Local Device Key (default 0123456789abcdef)") 20 | print(" is the Protocol Version 3.1 (default) or 3.3\n") 21 | print(" Note: You may also send values via Environmental variables: ") 22 | print(" DEVICEID, DEVICEIP, DEVICEKEY, DEVICEVERS\n") 23 | exit() 24 | DEVICEID = sys.argv[1] if len(sys.argv) >= 2 else "01234567891234567890" 25 | DEVICEIP = sys.argv[2] if len(sys.argv) >= 3 else "10.0.1.99" 26 | DEVICEKEY = sys.argv[3] if len(sys.argv) >= 4 else "0123456789abcdef" 27 | DEVICEVERS = sys.argv[4] if len(sys.argv) >= 5 else "3.1" 28 | 29 | # Check for environmental variables and always use those if available 30 | DEVICEID = os.getenv("DEVICEID", DEVICEID) 31 | DEVICEIP = os.getenv("DEVICEIP", DEVICEIP) 32 | DEVICEKEY = os.getenv("DEVICEKEY", DEVICEKEY) 33 | DEVICEVERS = os.getenv("DEVICEVERS", DEVICEVERS) 34 | 35 | print("TinyTuya (Tuya Interface) [%s]\n"%tinytuya.__version__) 36 | print('TESTING: Device %s at %s with key %s version %s' % (DEVICEID, DEVICEIP, DEVICEKEY,DEVICEVERS)) 37 | 38 | # Connect to device and fetch state 39 | RETRY = 2 40 | watchdog = 0 41 | while True: 42 | try: 43 | d = tinytuya.OutletDevice(DEVICEID, DEVICEIP, DEVICEKEY) 44 | d.set_version(float(DEVICEVERS)) 45 | data = d.status() 46 | if data: 47 | print("\nREADING TEST: Response %r" % data) 48 | print("State (bool, True is ON) %r\n" % data['dps']['1']) 49 | break 50 | else: 51 | print("Incomplete response from device %s [%s]." % (DEVICEID,DEVICEIP)) 52 | except: 53 | watchdog += 1 54 | if watchdog > RETRY: 55 | print( 56 | "TIMEOUT: No response from plug %s [%s] after %s attempts." 57 | % (DEVICEID,DEVICEIP, RETRY) 58 | ) 59 | 60 | # Toggle switch state 61 | print("CONTROL TEST: Attempting to toggle power state of device") 62 | switch_state = data['dps']['1'] 63 | for x in [(not switch_state), switch_state]: 64 | try: 65 | print("Setting state to: %r" % x) 66 | data = d.set_status(x) # This requires a valid key 67 | time.sleep(2) 68 | except: 69 | print("TIMEOUT trying to toggle device power.") 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /server/web/device.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | TinyTuya API Server - Device Details 5 | 6 | 7 | 8 | 9 |
10 |

Device Details

11 |
12 |

Device Status

13 |
14 | 15 | 85 | 86 |
87 |

Back

88 |
89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /tinytuya/CoverDevice.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Cover Device 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Python module to interface with Tuya WiFi smart devices 5 | 6 | Author: Jason A. Cox 7 | For more information see https://github.com/jasonacox/tinytuya 8 | 9 | Local Control Classes 10 | CoverDevice(...) 11 | See OutletDevice() for constructor arguments 12 | 13 | Functions 14 | CoverDevice: 15 | open_cover(switch=1): 16 | close_cover(switch=1): 17 | stop_cover(switch=1): 18 | 19 | Inherited 20 | json = status() # returns json payload 21 | set_version(version) # 3.1 [default] or 3.3 22 | set_socketPersistent(False/True) # False [default] or True 23 | set_socketNODELAY(False/True) # False or True [default] 24 | set_socketRetryLimit(integer) # retry count limit [default 5] 25 | set_socketTimeout(timeout) # set connection timeout in seconds [default 5] 26 | set_dpsUsed(dps_to_request) # add data points (DPS) to request 27 | add_dps_to_request(index) # add data point (DPS) index set to None 28 | set_retry(retry=True) # retry if response payload is truncated 29 | set_status(on, switch=1, nowait) # Set status of switch to 'on' or 'off' (bool) 30 | set_value(index, value, nowait) # Set int value of any index. 31 | heartbeat(nowait) # Send heartbeat to device 32 | updatedps(index=[1], nowait) # Send updatedps command to device 33 | turn_on(switch=1, nowait) # Turn on device / switch # 34 | turn_off(switch=1, nowait) # Turn off 35 | set_timer(num_secs, nowait) # Set timer for num_secs 36 | set_debug(toggle, color) # Activate verbose debugging output 37 | set_sendWait(num_secs) # Time to wait after sending commands before pulling response 38 | detect_available_dps() # Return list of DPS available from device 39 | generate_payload(command, data) # Generate TuyaMessage payload for command with data 40 | send(payload) # Send payload to device (do not wait for response) 41 | receive() 42 | """ 43 | 44 | from .core import Device 45 | 46 | class CoverDevice(Device): 47 | """ 48 | Represents a Tuya based Smart Window Cover. 49 | """ 50 | 51 | DPS_INDEX_MOVE = "1" 52 | DPS_INDEX_BL = "101" 53 | 54 | DPS_2_STATE = { 55 | "1": "movement", 56 | "101": "backlight", 57 | } 58 | 59 | def open_cover(self, switch=1, nowait=False): 60 | """Open the cover""" 61 | self.set_status("on", switch, nowait=nowait) 62 | 63 | def close_cover(self, switch=1, nowait=False): 64 | """Close the cover""" 65 | self.set_status("off", switch, nowait=nowait) 66 | 67 | def stop_cover(self, switch=1, nowait=False): 68 | """Stop the motion of the cover""" 69 | self.set_status("stop", switch, nowait=nowait) 70 | -------------------------------------------------------------------------------- /examples/Contrib/TuyaSmartPlug-example.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Smart Plug 1-Pack Example 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Example script using the tinytuya Python module for Tuya Smart Plug 1-Pack 5 | and stores data in mysql database. 6 | 7 | Author: fajarmnrozaki (https://github.com/fajarmnrozaki) 8 | For more information see https://github.com/jasonacox/tinytuya 9 | """ 10 | 11 | # Import library 12 | import datetime 13 | import tinytuya # code packet for communication between Tuya devices 14 | import time # RTC Real Time Clock 15 | import pymysql # library for sql 16 | 17 | # Specifications of Network scanner (the device Tuya must be turned "ON") 18 | Device_Id = 'xxxxxxxxxxxxxxxxxx' # Device Id from Tuya device sensor 19 | Address_Id = 'x.x.x.x' # IP Address connected to Tuya device sensor 20 | Local_Key = 'xxxxxxxxxxxxxxxx' # Local Key generated from python -m tinytuya wizard 21 | Version = 3.3 #Version of Tuya protocol used 22 | 23 | # Checking the connection "Tuya device - sensor" 24 | try: 25 | smartplug = tinytuya.OutletDevice(Device_Id, Address_Id, Local_Key) 26 | smartplug.set_version(Version) 27 | print("Connected to Tuya device sensor") 28 | except: 29 | print("Disconnected to Tuya device sensor") 30 | smartplug.close() 31 | 32 | # Monitoring a Tuya Smart Plug Device Sensor 33 | while True: 34 | try: 35 | # Time 36 | timer = datetime.datetime.now() 37 | print("Time :",timer.strftime("%Y-%m-%d %H:%M:%S")) 38 | # Get Status of Tuya device sensor 39 | data = smartplug.status() 40 | print("set_status() result", data) 41 | # Voltage # DPS (Data Points) 42 | print("Voltage :", (data['dps']['20'])/10,"Voltage") 43 | # Current # DPS (Data Points) 44 | print("Current :", (data['dps']['18'])/1000,"Ampere") 45 | # Power # DPS (Data Points) 46 | print("Power :", (data['dps']['19'])/10,"Watt") 47 | print('') 48 | 49 | # Turn On 50 | smartplug.turn_on() 51 | 52 | # Database Connection 53 | # in thise example, the data is sent to RDS (Relational Database Service) MySQL 54 | # Change the [host],[user],[password], [db] and [querry] with your own version 55 | 56 | db = pymysql.connect(host='***', 57 | user='***', 58 | password='***', 59 | db='****', 60 | charset='utf8', 61 | cursorclass=pymysql.cursors.DictCursor) 62 | cur = db.cursor() 63 | 64 | add_c0 = "INSERT INTO `tuya_smart_plug`(time, voltage, current, power) VALUES (%s,%s,%s,%s)" 65 | cur.execute(add_c0,((timer.strftime("%Y-%m-%d %H:%M:%S"), 66 | (data['dps']['20'])/10, 67 | (data['dps']['18'])/1000, 68 | (data['dps']['19'])/10))) 69 | db.commit() 70 | 71 | time.sleep(60) # this python script example is set for monitoring a Tuya Smart Plug Device Sensor every 60 seconds 72 | 73 | except: 74 | print("============") 75 | print("Disconnected") 76 | print("============") 77 | # time.sleep(0) 78 | pass 79 | -------------------------------------------------------------------------------- /examples/devices.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Example 2 | # -*- coding: utf-8 -*- 3 | """ 4 | TinyTuya - Example to poll status of all devices in Devices.json 5 | 6 | Author: Jason A. Cox 7 | For more information see https://github.com/jasonacox/tinytuya 8 | 9 | """ 10 | import tinytuya 11 | import json 12 | import time 13 | 14 | DEVICEFILE = 'devices.json' 15 | SNAPSHOTFILE = 'snapshot.json' 16 | havekeys = False 17 | tuyadevices = [] 18 | 19 | # Terminal Color Formatting 20 | bold = "\033[0m\033[97m\033[1m" 21 | subbold = "\033[0m\033[32m" 22 | normal = "\033[97m\033[0m" 23 | dim = "\033[0m\033[97m\033[2m" 24 | alert = "\033[0m\033[91m\033[1m" 25 | alertdim = "\033[0m\033[91m\033[2m" 26 | 27 | # Lookup Tuya device info by (id) returning (name, key) 28 | def tuyaLookup(deviceid): 29 | for i in tuyadevices: 30 | if (i['id'] == deviceid): 31 | return (i['name'], i['key']) 32 | return ("", "") 33 | 34 | # Read Devices.json 35 | try: 36 | # Load defaults 37 | with open(DEVICEFILE) as f: 38 | tuyadevices = json.load(f) 39 | havekeys = True 40 | except: 41 | # No Device info 42 | print(alert + "\nNo devices.json file found." + normal) 43 | exit() 44 | 45 | # Scan network for devices and provide polling data 46 | print(normal + "\nScanning local network for Tuya devices...") 47 | devices = tinytuya.deviceScan(False, 30) 48 | print(" %s%s local active devices discovered%s" % 49 | (dim, len(devices), normal)) 50 | print("") 51 | 52 | def getIP(d, gwid): 53 | for ip in d: 54 | if (gwid == d[ip]['gwId']): 55 | return (ip, d[ip]['version']) 56 | return (0, 0) 57 | 58 | polling = [] 59 | print("Polling local devices...") 60 | for i in tuyadevices: 61 | item = {} 62 | name = i['name'] 63 | (ip, ver) = getIP(devices, i['id']) 64 | item['name'] = name 65 | item['ip'] = ip 66 | item['ver'] = ver 67 | item['id'] = i['id'] 68 | item['key'] = i['key'] 69 | if (ip == 0): 70 | print(" %s[%s] - %s%s - %sError: No IP found%s" % 71 | (subbold, name, dim, ip, alert, normal)) 72 | else: 73 | try: 74 | d = tinytuya.OutletDevice(i['id'], ip, i['key']) 75 | d.set_version(float(ver)) # IMPORTANT to always set version 76 | data = d.status() 77 | if 'dps' in data: 78 | item['dps'] = data 79 | state = alertdim + "Off" + dim 80 | try: 81 | if '1' in data['dps'] or '20' in data['dps']: 82 | state = bold + "On" + dim 83 | print(" %s[%s] - %s%s - %s - DPS: %r" % 84 | (subbold, name, dim, ip, state, data['dps'])) 85 | else: 86 | print(" %s[%s] - %s%s - DPS: %r" % 87 | (subbold, name, dim, ip, data['dps'])) 88 | except: 89 | print(" %s[%s] - %s%s - %sNo Response" % 90 | (subbold, name, dim, ip, alertdim)) 91 | else: 92 | print(" %s[%s] - %s%s - %sNo Response" % 93 | (subbold, name, dim, ip, alertdim)) 94 | except: 95 | print(" %s[%s] - %s%s - %sNo Response" % 96 | (subbold, name, dim, ip, alertdim)) 97 | polling.append(item) 98 | # for loop 99 | 100 | # Save polling data snapshot.json 101 | tinytuya.scanner.save_snapshotfile( SNAPSHOTFILE, polling, None ) 102 | -------------------------------------------------------------------------------- /tinytuya/Contrib/SocketDevice.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Outlet Device 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Python module to interface with Tuya Socket Devices 5 | 6 | Author: Felix Pieschka 7 | For more information see https://github.com/Felix-Pi 8 | 9 | Local Control Classes 10 | SocketDevice(...) 11 | See OutletDevice() for constructor arguments 12 | 13 | Functions 14 | SocketDevice: 15 | get_energy_consumption() 16 | get_current() 17 | get_power() 18 | get_get_voltage() 19 | get_state() 20 | Inherited 21 | json = status() # returns json payload 22 | set_version(version) # 3.1 [default] or 3.3 23 | set_socketPersistent(False/True) # False [default] or True 24 | set_socketNODELAY(False/True) # False or True [default] 25 | set_socketRetryLimit(integer) # retry count limit [default 5] 26 | set_socketTimeout(timeout) # set connection timeout in seconds [default 5] 27 | set_dpsUsed(dps_to_request) # add data points (DPS) to request 28 | add_dps_to_request(index) # add data point (DPS) index set to None 29 | set_retry(retry=True) # retry if response payload is truncated 30 | set_status(on, switch=1, nowait) # Set status of switch to 'on' or 'off' (bool) 31 | set_value(index, value, nowait) # Set int value of any index. 32 | heartbeat(nowait) # Send heartbeat to device 33 | updatedps(index=[1], nowait) # Send updatedps command to device 34 | turn_on(switch=1, nowait) # Turn on device / switch # 35 | turn_off(switch=1, nowait) # Turn off 36 | set_timer(num_secs, nowait) # Set timer for num_secs 37 | set_debug(toggle, color) # Activate verbose debugging output 38 | set_sendWait(num_secs) # Time to wait after sending commands before pulling response 39 | detect_available_dps() # Return list of DPS available from device 40 | generate_payload(command, data) # Generate TuyaMessage payload for command with data 41 | send(payload) # Send payload to device (do not wait for response) 42 | receive() 43 | """ 44 | 45 | from ..core import Device 46 | 47 | 48 | class SocketDevice(Device): 49 | """ 50 | Represents a Tuya based Socket 51 | """ 52 | 53 | DPS_STATE = '1' 54 | DPS_CURRENT = '18' 55 | DPS_POWER = '19' 56 | DPS_VOLTAGE = '20' 57 | 58 | def get_energy_consumption(self): 59 | data = self.status() 60 | return {**self.get_current(data), **self.get_power(data), **self.get_voltage(data)} 61 | 62 | def get_current(self, status_data=None): 63 | if status_data is None: 64 | status_data = self.status() 65 | 66 | current = status_data['dps'][self.DPS_CURRENT] 67 | 68 | return {'current_raw': current, 69 | 'current_fmt': str(current) + ' mA', } 70 | 71 | def get_power(self, status_data=None): 72 | if status_data is None: 73 | status_data = self.status() 74 | 75 | power = status_data['dps'][self.DPS_POWER] / 10 76 | 77 | return {'power_raw': power, 78 | 'power_fmt': str(power) + ' W', } 79 | 80 | def get_voltage(self, status_data=None): 81 | if status_data is None: 82 | status_data = self.status() 83 | 84 | voltage = status_data['dps'][self.DPS_VOLTAGE] / 10 85 | 86 | return {'voltage_raw': voltage, 87 | 'voltage_fmt': str(voltage) + ' V'} 88 | 89 | def get_state(self): 90 | return {'on': self.status()['dps'][self.DPS_STATE]} 91 | -------------------------------------------------------------------------------- /examples/Contrib/XmCosyStringLight-example.py: -------------------------------------------------------------------------------- 1 | # Works with XmCosy+ RGBW string lights. 2 | # Model # DMD-045-W3 3 | # FCC ID: 2AI5T-DMD-045-W3 4 | # Amazon SKU: B0B5D643VV 5 | # 6 | # Tested with the above mentioned RGBW string lights and a string of 6 RGBCW flood lights. 7 | # Both use Tuya controllers and are made by the Shenzhen Bling Lighting Technologies Co., Ltd. 8 | # FCC ID of the tested flood lights is 2AI5T-LSE-048-W3 and Amazon SKU is B0CFV8TGBH. 9 | # 10 | # Author: Glen Akins, https://github.com/bikerglen 11 | # Date: January 2024 12 | # 13 | # Format of the color tuple in main is 14 | # 15 | # ( HSI Flag, Hue, Sat, Int, CW, WW ) 16 | # 17 | # HSI Flag = 0 for CW/WW mixing, 1 for HSI mixing 18 | # 19 | # If HSI Flag is 1: 20 | # Hue is 0 to 359, 0 is red, 120 is green, 240 is blue 21 | # Sat is 0 to 100 22 | # Int is 0 to 100 23 | # CW is 0 24 | # WW is 0 25 | # 26 | # If HSI Flag is 0: 27 | # Hue is 0 28 | # Sat is 0 29 | # Int is 0 30 | # CW is 0 to 100 31 | # WW is 0 to 100 32 | # 33 | # When using the smart life app's diy feature to set WW, NW, or CW: 34 | # 35 | # WW is 0, 100 36 | # NW is 50, 100 37 | # CW is 100, 100 38 | # 39 | # Hue is 2 bytes, MSB first. The rest are 1 byte each. 40 | # 41 | 42 | import tinytuya 43 | import time 44 | import base64 45 | 46 | # replace the x's with the data for your light string, IP is the local IP, not the cloud IP 47 | DEVICE_IP = "x.x.x.x" 48 | DEVICE_ID = "xxxxxxxxxxxxxxxxxxxxxx" 49 | DEVICE_KEY = "xxxxxxxxxxxxxxxx" 50 | DEVICE_VER = 3.3 51 | 52 | def xmcosy_string_lights_encode_colors (lights, colors, offset): 53 | 54 | # header is 8 bytes and always the same 55 | header = b'\x00\xff\x00\x00\x00\x80\x01\x00' 56 | 57 | # replicate the specified colors across the specified number of lights as many times as possible 58 | light = 0 59 | index = offset 60 | levels = [] 61 | for light in range (lights): 62 | levels.append (colors[index]) 63 | index += 1 64 | if index >= len(colors): 65 | index = 0 66 | 67 | # form the data byte string by combining the header and all the encoded light level tuples 68 | data = header 69 | for light in range (lights): 70 | encoded_level = levels[light][0].to_bytes (1, 'big') # hsi/white flag 71 | encoded_level += levels[light][1].to_bytes (2, 'big') # hue, 2 bytes, MSB first 72 | encoded_level += levels[light][2].to_bytes (1, 'big') # saturation 73 | encoded_level += levels[light][3].to_bytes (1, 'big') # intensity 74 | encoded_level += levels[light][4].to_bytes (1, 'big') # cool white 75 | encoded_level += levels[light][5].to_bytes (1, 'big') # warm white 76 | data += encoded_level 77 | 78 | # base 64 encode the data string and convert to ascii 79 | b64 = base64.b64encode (data).decode ('ascii') 80 | 81 | return b64 82 | 83 | if __name__ == '__main__': 84 | 85 | # 30 lights 86 | lights = 30 87 | 88 | # these 6 colors will be replicated 5 times across the 30 lights 89 | colors = [ 90 | ( 1, 0, 100, 100, 0, 0 ), # RED 91 | ( 1, 60, 100, 100, 0, 0 ), # YELLOW 92 | ( 1, 120, 100, 100, 0, 0 ), # GREEN 93 | ( 1, 180, 100, 100, 0, 0 ), # CYAN 94 | ( 1, 240, 100, 100, 0, 0 ), # BLUE 95 | ( 1, 300, 100, 100, 0, 0 ), # MAGENTA 96 | ] 97 | 98 | """ 99 | # these 3 color temps will be replicated 10 times across the 30 lights 100 | colors = [ 101 | ( 0, 0, 0, 0, 0, 100 ), # WW 102 | ( 0, 0, 0, 0, 50, 100 ), # NW 103 | ( 0, 0, 0, 0, 100, 100 ), # CW 104 | ] 105 | """ 106 | 107 | # make the colors chase down the string 108 | d = tinytuya.BulbDevice(DEVICE_ID, DEVICE_IP, DEVICE_KEY, version=DEVICE_VER, persist=False) 109 | while True: 110 | for i in range (len(colors)): 111 | d102 = xmcosy_string_lights_encode_colors (lights, colors, len(colors)-1-i) 112 | d.set_value (102, d102) 113 | time.sleep(1) 114 | -------------------------------------------------------------------------------- /tinytuya/OutletDevice.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Outlet Device 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Python module to interface with Tuya WiFi smart devices 5 | 6 | Author: Jason A. Cox 7 | For more information see https://github.com/jasonacox/tinytuya 8 | 9 | Local Control Classes 10 | OutletDevice(dev_id, address=None, local_key=None, dev_type='default', connection_timeout=5, version=3.1, persist=False 11 | dev_id (str): Device ID e.g. 01234567891234567890 12 | address (str, optional): Device Network IP Address e.g. 10.0.1.99, or None to try and find the device 13 | local_key (str, optional): The encryption key. Defaults to None. If None, key will be looked up in DEVICEFILE if available 14 | dev_type (str, optional): Device type for payload options (see below) 15 | connection_timeout (float, optional): The default socket connect and data timeout 16 | version (float, optional): The API version to use. Defaults to 3.1 17 | persist (bool, optional): Make a persistant connection to the device 18 | 19 | Functions 20 | OutletDevice: 21 | set_dimmer(percentage): 22 | 23 | Inherited 24 | json = status() # returns json payload 25 | set_version(version) # 3.1 [default] or 3.3 26 | set_socketPersistent(False/True) # False [default] or True 27 | set_socketNODELAY(False/True) # False or True [default] 28 | set_socketRetryLimit(integer) # retry count limit [default 5] 29 | set_socketTimeout(timeout) # set connection timeout in seconds [default 5] 30 | set_dpsUsed(dps_to_request) # add data points (DPS) to request 31 | add_dps_to_request(index) # add data point (DPS) index set to None 32 | set_retry(retry=True) # retry if response payload is truncated 33 | set_status(on, switch=1, nowait) # Set status of switch to 'on' or 'off' (bool) 34 | set_value(index, value, nowait) # Set int value of any index. 35 | heartbeat(nowait) # Send heartbeat to device 36 | updatedps(index=[1], nowait) # Send updatedps command to device 37 | turn_on(switch=1, nowait) # Turn on device / switch # 38 | turn_off(switch=1, nowait) # Turn off 39 | set_timer(num_secs, nowait) # Set timer for num_secs 40 | set_debug(toggle, color) # Activate verbose debugging output 41 | set_sendWait(num_secs) # Time to wait after sending commands before pulling response 42 | detect_available_dps() # Return list of DPS available from device 43 | generate_payload(command, data) # Generate TuyaMessage payload for command with data 44 | send(payload) # Send payload to device (do not wait for response) 45 | receive() 46 | """ 47 | 48 | from .core import Device 49 | 50 | class OutletDevice(Device): 51 | """ 52 | Represents a Tuya based Smart Plug or Switch. 53 | """ 54 | 55 | def set_dimmer(self, percentage=None, value=None, dps_id=3, nowait=False): 56 | """Set dimmer value 57 | 58 | Args: 59 | percentage (int): percentage dim 0-100 60 | value (int): direct value for switch 0-255 61 | dps_id (int): DPS index for dimmer value 62 | nowait (bool): True to send without waiting for response. 63 | """ 64 | 65 | if percentage is not None: 66 | level = int(percentage * 255.0 / 100.0) 67 | else: 68 | level = value 69 | 70 | if level == 0: 71 | self.turn_off(nowait=nowait) 72 | elif level is not None: 73 | if level < 25: 74 | level = 25 75 | if level > 255: 76 | level = 255 77 | self.turn_on(nowait=nowait) 78 | self.set_value(dps_id, level, nowait=nowait) 79 | -------------------------------------------------------------------------------- /testcontrib.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | TinyTuya test for Contrib 4 | 5 | Author: Jason A. Cox 6 | For more information see https://github.com/jasonacox/tinytuya 7 | """ 8 | import tinytuya 9 | from tinytuya import Contrib 10 | 11 | print("TinyTuya (Contrib Import Test) [%s]\n" % tinytuya.__version__) 12 | 13 | print(" Contrib Devices Loaded: ") 14 | for i in Contrib.DeviceTypes: 15 | print(" * %s" % i) 16 | 17 | print(" Test ThermostatDevice init(): ") 18 | d = Contrib.ThermostatDevice("abcdefghijklmnop123456", "172.28.321.475", "1234567890123abc") 19 | 20 | import time 21 | import os 22 | 23 | # Load environment variables from .env file 24 | try: 25 | from dotenv import load_dotenv 26 | load_dotenv() 27 | except ModuleNotFoundError: 28 | pass # dotenv not installed, ignore 29 | 30 | IHP_DEVICEID = os.getenv("IHP_DEVICEID", None) 31 | IHP_DEVICEIP = os.getenv("IHP_DEVICEIP", None) 32 | IHP_DEVICEKEY = os.getenv("IHP_DEVICEKEY", None) 33 | IHP_DEVICEVERS = os.getenv("IHP_DEVICEVERS", None) 34 | 35 | if IHP_DEVICEID and IHP_DEVICEIP and IHP_DEVICEKEY and IHP_DEVICEVERS: 36 | print(" Test InverterHeatPumpDevice: ") 37 | print(" * Device ID: %s" % IHP_DEVICEID) 38 | print(" * Device IP: %s" % IHP_DEVICEIP) 39 | print(" * Device Key: %s" % IHP_DEVICEKEY) 40 | print(" * Device Version: %s" % IHP_DEVICEVERS) 41 | print() 42 | 43 | device = Contrib.InverterHeatPumpDevice( 44 | dev_id=IHP_DEVICEID, address=IHP_DEVICEIP, local_key=IHP_DEVICEKEY, version=IHP_DEVICEVERS 45 | ) 46 | 47 | is_on = device.is_on() 48 | unit = device.get_unit() 49 | target_water_temp = device.get_target_water_temp() 50 | lower_limit_target_water_temp = device.get_lower_limit_target_water_temp() 51 | is_silence_mode = device.is_silence_mode() 52 | 53 | print(" * is_on(): %r" % is_on) 54 | print(" * get_unit(): %r" % unit) 55 | print(" * get_inlet_water_temp(): %r" % device.get_inlet_water_temp()) 56 | print(" * get_target_water_temp(): %r" % target_water_temp) 57 | print(" * get_lower_limit_target_water_temp(): %r" % lower_limit_target_water_temp) 58 | print(" * get_upper_limit_target_water_temp(): %r" % device.get_upper_limit_target_water_temp()) 59 | print(" * get_heating_capacity_percent(): %r" % device.get_heating_capacity_percent()) 60 | print(" * get_mode(): %r" % device.get_mode()) 61 | print(" * get_mode(raw=True): %r" % device.get_mode(raw=True)) 62 | print(" * get_fault(): %r" % device.get_fault()) 63 | print(" * get_fault(raw=True): %r" % device.get_fault(raw=True)) 64 | print(" * is_silence_mode(): %r" % is_silence_mode) 65 | 66 | time.sleep(10) 67 | 68 | print(" Toggle ON/OFF") 69 | for power_state in [not is_on, is_on]: 70 | print(" * Turning %s" % ("ON" if power_state else "OFF")) 71 | device.turn_on() if power_state else device.turn_off() 72 | time.sleep(5) 73 | print(" * is_on(): %r" % device.is_on()) 74 | time.sleep(10) 75 | 76 | print(" Toggle unit") 77 | for unit_value in [not unit.value, unit.value]: 78 | print(" * Setting unit to %r" % Contrib.TemperatureUnit(unit_value)) 79 | device.set_unit(Contrib.TemperatureUnit(unit_value)) 80 | time.sleep(5) 81 | print(" * get_unit(): %r" % device.get_unit()) 82 | time.sleep(5) 83 | 84 | print(" Set target water temperature to lower limit and previous value") 85 | for target_water_temp_value in [lower_limit_target_water_temp, target_water_temp]: 86 | print(" * Setting target water temperature to %r" % target_water_temp_value) 87 | device.set_target_water_temp(target_water_temp_value) 88 | time.sleep(5) 89 | print(" * get_target_water_temp(): %r" % device.get_target_water_temp()) 90 | time.sleep(5) 91 | 92 | print(" Toggle silence mode") 93 | for silence_mode in [not is_silence_mode, is_silence_mode]: 94 | print(" * Setting silence mode to %r" % silence_mode) 95 | device.set_silence_mode(silence_mode) 96 | time.sleep(5) 97 | print(" * is_silence_mode(): %r" % device.is_silence_mode()) 98 | time.sleep(5) 99 | 100 | exit() 101 | -------------------------------------------------------------------------------- /tools/fake-v35-device.py: -------------------------------------------------------------------------------- 1 | 2 | import ttcorefunc as tinytuya 3 | import socket 4 | import select 5 | import time 6 | import json 7 | from hashlib import md5, sha256 8 | import hmac 9 | 10 | bind_host = '' 11 | bind_port = 6668 12 | 13 | # can also be set to the address of a hub/gateway device or phone running SmartLife 14 | bcast_to = '127.0.0.1' 15 | 16 | bcast_data = b'{"ip":"127.0.0.1","gwId":"eb0123456789abcdefghij","active":2,"ablilty":0,"encrypt":true,"productKey":"keydeadbeef12345","version":"3.5","token":true,"wf_cfg":true}' 17 | real_key = b'thisisarealkey00' 18 | local_nonce = str(time.time() * 1000000)[:16].encode('utf8') #b'0123456789abcdef' 19 | 20 | msg = tinytuya.TuyaMessage(1, tinytuya.UDP_NEW, 0, bcast_data, 0, True, tinytuya.PREFIX_6699_VALUE, True) 21 | bcast_data = tinytuya.pack_message(msg,hmac_key=tinytuya.udpkey) 22 | print("broadcast encrypted=%r" % bcast_data.hex() ) 23 | 24 | 25 | srv = socket.socket( socket.AF_INET6, socket.SOCK_STREAM ) 26 | srv.setsockopt( socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 ) 27 | srv.bind( (bind_host, bind_port) ) 28 | srv.listen( 1 ) 29 | 30 | bsock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP 31 | bsock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 32 | 33 | client = None 34 | 35 | bcast_time = 0 36 | 37 | while True: 38 | r = [srv] 39 | if client: r.append( client ) 40 | w = [] 41 | x = [] 42 | 43 | r, w, x = select.select( r, w, x, 1 ) 44 | #print('select') 45 | 46 | if( bcast_time < time.time() ): 47 | bcast_time = time.time() + 8 48 | #print( 'bcast' ) 49 | bsock.sendto( bcast_data, (bcast_to, 6667) ) 50 | 51 | for sock in r: 52 | if sock is srv: 53 | if client: 54 | client.close() 55 | client = None 56 | client, addr = sock.accept() 57 | client.setblocking( False ) 58 | tmp_key = real_key 59 | seqno = 1 60 | print( 'new client connected:', addr ) 61 | continue 62 | 63 | if sock is not client: 64 | print('not:', sock) 65 | continue 66 | 67 | data = sock.recv( 4096 ) 68 | #print( 'client data: %r' % data ) 69 | if not data: 70 | client.close() 71 | client = None 72 | continue 73 | 74 | print('') 75 | print('client sent:', data) 76 | #print(data.hex()) 77 | m = tinytuya.unpack_message(data,hmac_key=tmp_key, no_retcode=True) 78 | #print('payload len:', len(m.payload), 'tuya message:', m) 79 | print('decoded message:', m) 80 | 81 | if m.cmd == tinytuya.SESS_KEY_NEG_START: 82 | tmp_key = real_key 83 | payload = m.payload 84 | remote_nonce = payload 85 | miv = remote_nonce[:12] 86 | hmac_check = hmac.new(real_key, remote_nonce, sha256).digest() 87 | msg = tinytuya.TuyaMessage(seqno, tinytuya.SESS_KEY_NEG_RESP, 0, local_nonce+hmac_check, 0, True, tinytuya.PREFIX_6699_VALUE, True) 88 | seqno += 1 89 | data = tinytuya.pack_message(msg, hmac_key=tmp_key) 90 | print( 'session neg start:', msg ) 91 | client.sendall( data ) 92 | elif m.cmd == tinytuya.SESS_KEY_NEG_FINISH: 93 | rkey_hmac = hmac.new(real_key, local_nonce, sha256).digest() 94 | print('neg fin. success:', rkey_hmac == m.payload) 95 | print('want hmac:', rkey_hmac.hex()) 96 | print('got hmac: ', m.payload.hex()) 97 | tmp_key = bytes( [ a^b for (a,b) in zip(remote_nonce,local_nonce) ] ) 98 | print( 'sess nonce:', tmp_key.hex() ) 99 | cipher = tinytuya.AESCipher( real_key ) 100 | print( 'sess iv:', m.iv.hex() ) 101 | tmp_key = cipher.encrypt( tmp_key, use_base64=False, pad=False, iv=miv )[12:28] 102 | print( 'sess key:', tmp_key.hex(), tmp_key) 103 | elif m.cmd == tinytuya.DP_QUERY_NEW: 104 | print('got status request') 105 | resp = {'protocol': 4, 't': int(time.time()), 'data': {'dps': {'20': True, '21': 'white', '22': 946, '23': 3, '24': '014a03e803a9', '25': '04464602007803e803e800000000464602007803e8000a00000000', '26': 0, '34': False}} } 106 | msg = tinytuya.TuyaMessage(seqno, 16, 0, json.dumps(resp).encode('ascii'), 0, True, tinytuya.PREFIX_6699_VALUE, True) 107 | seqno += 1 108 | data = tinytuya.pack_message(msg, hmac_key=tmp_key) 109 | client.sendall( data ) 110 | else: 111 | print('unhandled command', m.cmd) 112 | msg = tinytuya.TuyaMessage(seqno, 16, 0, b'json obj data unvalid', 0, True, tinytuya.PREFIX_6699_VALUE, True) 113 | seqno += 1 114 | data = tinytuya.pack_message(msg, hmac_key=tmp_key) 115 | client.sendall( data ) 116 | 117 | 118 | -------------------------------------------------------------------------------- /tinytuya/Contrib/PresenceDetectorDevice.py: -------------------------------------------------------------------------------- 1 | from ..core import Device 2 | import time 3 | import json 4 | 5 | class PresenceDetectorDevice(Device): 6 | """ 7 | Represents a Tuya-based Presence Detector. 8 | """ 9 | 10 | DPS_KEY = "dps" 11 | PRESENCE_KEY = "1" 12 | SENSITIVITY_KEY = "2" 13 | NEAR_DETECTION_KEY = "3" 14 | FAR_DETECTION_KEY = "4" 15 | AUTO_DETECT_RESULT_KEY = "6" 16 | TARGET_DISTANCE_KEY = "9" 17 | DETECTION_DELAY_KEY = "101" 18 | FADING_TIME_KEY = "102" 19 | LIGHT_SENSE_KEY = "104" 20 | 21 | def __init__(self, *args, **kwargs): 22 | # set the default version to 3.3 as there are no 3.1 devices 23 | if 'version' not in kwargs or not kwargs['version']: 24 | kwargs['version'] = 3.3 25 | super(PresenceDetectorDevice, self).__init__(*args, **kwargs) 26 | 27 | def status_json(self): 28 | """Wrapper around status() that replace DPS indices with human readable labels.""" 29 | status = self.status() 30 | if "Error" in status: 31 | return status 32 | dps = status[self.DPS_KEY] 33 | json_string = json.dumps({ 34 | "Presence": dps[self.PRESENCE_KEY], 35 | "Sensitivity": dps[self.SENSITIVITY_KEY], 36 | "Near detection": dps[self.NEAR_DETECTION_KEY], 37 | "Far detection": dps[self.FAR_DETECTION_KEY], 38 | "Checking result": dps[self.AUTO_DETECT_RESULT_KEY], 39 | "Target distance": dps[self.TARGET_DISTANCE_KEY], 40 | "Detection delay": dps[self.DETECTION_DELAY_KEY], 41 | "Fading time": dps[self.FADING_TIME_KEY], 42 | "Light sense": dps[self.LIGHT_SENSE_KEY] 43 | }) 44 | return json_string 45 | 46 | def status(self): 47 | """In some cases the status json we received is not the standard one with all the proper keys. We will re-try 5 to get the expected one""" 48 | status = super().status() 49 | if "Error" in status: 50 | return status 51 | dps = status[self.DPS_KEY] 52 | retry = 5 53 | while(retry > 0 and not self.PRESENCE_KEY in dps): 54 | retry = retry - 1 55 | status = super().status() 56 | dps = status[self.DPS_KEY] 57 | time.sleep(5) 58 | return status 59 | 60 | def get_presence_state(self): 61 | """Get the presence state of the Presence Detector. 62 | 63 | Returns: 64 | str: Presence state ("none" or "presence"). 65 | """ 66 | status = self.status() 67 | if "Error" in status: 68 | return status 69 | return status[self.DPS_KEY][self.PRESENCE_KEY] 70 | 71 | def get_sensitivity(self): 72 | """Get the sensitivity level of the Presence Detector. 73 | 74 | Returns: 75 | int: Sensitivity level (0 to 9). 76 | """ 77 | status = self.status() 78 | if "Error" in status: 79 | return status 80 | return status[self.DPS_KEY][self.SENSITIVITY_KEY] 81 | 82 | def set_sensitivity(self, sensitivity): 83 | self.set_value(self.SENSITIVITY_KEY, sensitivity) 84 | 85 | def get_near_detection(self): 86 | """Get the near detection distance of the Presence Detector. 87 | 88 | Returns: 89 | int: Near detection distance in meters. 90 | """ 91 | status = self.status() 92 | if "Error" in status: 93 | return status 94 | return status[self.DPS_KEY][self.NEAR_DETECTION_KEY] 95 | 96 | def set_near_detection(self, distance): 97 | self.set_value(self.NEAR_DETECTION_KEY, distance) 98 | 99 | def get_far_detection(self): 100 | """Get the far detection distance of the Presence Detector. 101 | 102 | Returns: 103 | int: Far detection distance in meters. 104 | """ 105 | status = self.status() 106 | if "Error" in status: 107 | return status 108 | return status[self.DPS_KEY][self.FAR_DETECTION_KEY] 109 | 110 | def set_far_detection(self, distance): 111 | self.set_value(self.FAR_DETECTION_KEY, distance) 112 | 113 | def get_checking_result(self): 114 | """Get the checking result of the Presence Detector. 115 | 116 | Returns: 117 | str: Checking result (one of ["checking", "check_success", "check_failure", "others", "comm_fault", "radar_fault"]). 118 | """ 119 | status = self.status() 120 | if "Error" in status: 121 | return status 122 | return status[self.DPS_KEY][self.AUTO_DETECT_RESULT_KEY] 123 | 124 | def get_target_distance(self): 125 | """Get the closest target distance of the Presence Detector. 126 | 127 | Returns: 128 | int: Closest target distance in meters. 129 | """ 130 | status = self.status() 131 | if "Error" in status: 132 | return status 133 | return status[self.DPS_KEY][self.TARGET_DISTANCE_KEY] 134 | -------------------------------------------------------------------------------- /tinytuya/__init__.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Module 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Python module to interface with Tuya WiFi smart devices 5 | 6 | Author: Jason A. Cox 7 | For more information see https://github.com/jasonacox/tinytuya 8 | 9 | Local Control Classes 10 | OutletDevice(dev_id, address=None, local_key=None, dev_type='default', connection_timeout=5, version=3.1, persist=False) 11 | CoverDevice(...) 12 | BulbDevice(...) 13 | Device(...) 14 | dev_id (str): Device ID e.g. 01234567891234567890 15 | address (str, optional): Device Network IP Address e.g. 10.0.1.99, or None to try and find the device 16 | local_key (str, optional): The encryption key. Defaults to None. If None, key will be looked up in DEVICEFILE if available 17 | dev_type (str, optional): Device type for payload options (see below) 18 | connection_timeout (float, optional): The default socket connect and data timeout 19 | version (float, optional): The API version to use. Defaults to 3.1 20 | persist (bool, optional): Make a persistant connection to the device 21 | Cloud(apiRegion, apiKey, apiSecret, apiDeviceID, new_sign_algorithm) 22 | 23 | Functions 24 | Device(XenonDevice) 25 | json = status() # returns json payload 26 | set_version(version) # 3.1 [default] or 3.3 27 | set_socketPersistent(False/True) # False [default] or True 28 | set_socketNODELAY(False/True) # False or True [default] 29 | set_socketRetryLimit(integer) # retry count limit [default 5] 30 | set_socketTimeout(timeout) # set connection timeout in seconds [default 5] 31 | set_dpsUsed(dps_to_request) # add data points (DPS) to request 32 | add_dps_to_request(index) # add data point (DPS) index set to None 33 | set_retry(retry=True) # retry if response payload is truncated 34 | set_status(on, switch=1, nowait) # Set status of switch to 'on' or 'off' (bool) 35 | set_value(index, value, nowait) # Set int value of any index. 36 | heartbeat(nowait) # Send heartbeat to device 37 | updatedps(index=[1], nowait) # Send updatedps command to device 38 | turn_on(switch=1, nowait) # Turn on device / switch # 39 | turn_off(switch=1, nowait) # Turn off 40 | set_timer(num_secs, nowait) # Set timer for num_secs 41 | set_debug(toggle, color) # Activate verbose debugging output 42 | set_sendWait(num_secs) # Time to wait after sending commands before pulling response 43 | detect_available_dps() # Return list of DPS available from device 44 | generate_payload(command, data) # Generate TuyaMessage payload for command with data 45 | send(payload) # Send payload to device (do not wait for response) 46 | receive() # Receive payload from device 47 | 48 | OutletDevice: 49 | set_dimmer(percentage): 50 | 51 | CoverDevice: 52 | open_cover(switch=1): 53 | close_cover(switch=1): 54 | stop_cover(switch=1): 55 | 56 | BulbDevice 57 | set_colour(r, g, b, nowait): 58 | set_hsv(h, s, v, nowait): 59 | set_white(brightness, colourtemp, nowait): 60 | set_white_percentage(brightness=100, colourtemp=0, nowait): 61 | set_brightness(brightness, nowait): 62 | set_brightness_percentage(brightness=100, nowait): 63 | set_colourtemp(colourtemp, nowait): 64 | set_colourtemp_percentage(colourtemp=100, nowait): 65 | set_scene(scene, nowait): # 1=nature, 3=rave, 4=rainbow 66 | set_mode(mode='white', nowait): # white, colour, scene, music 67 | result = brightness(): 68 | result = colourtemp(): 69 | (r, g, b) = colour_rgb(): 70 | (h,s,v) = colour_hsv() 71 | result = state(): 72 | 73 | Cloud 74 | setregion(apiRegion) 75 | getdevices(verbose=False) 76 | getstatus(deviceid) 77 | getfunctions(deviceid) 78 | getproperties(deviceid) 79 | getdps(deviceid) 80 | sendcommand(deviceid, commands) 81 | 82 | Credits 83 | * TuyaAPI https://github.com/codetheweb/tuyapi by codetheweb and blackrozes 84 | For protocol reverse engineering 85 | * PyTuya https://github.com/clach04/python-tuya by clach04 86 | The origin of this python module (now abandoned) 87 | * LocalTuya https://github.com/rospogrigio/localtuya-homeassistant by rospogrigio 88 | Updated pytuya to support devices with Device IDs of 22 characters 89 | 90 | """ 91 | 92 | from .core import * 93 | from .core import __version__ 94 | from .core import __author__ 95 | 96 | from .OutletDevice import OutletDevice 97 | from .CoverDevice import CoverDevice 98 | from .BulbDevice import BulbDevice 99 | from .Cloud import Cloud 100 | -------------------------------------------------------------------------------- /examples/Contrib/ThermostatDevice-example.py: -------------------------------------------------------------------------------- 1 | 2 | # TinyTuya ThermostatDevice Example 3 | # -*- coding: utf-8 -*- 4 | """ 5 | Example script using the community-contributed Python module for Tuya WiFi smart thermostats 6 | 7 | Author: uzlonewolf (https://github.com/uzlonewolf) 8 | For more information see https://github.com/jasonacox/tinytuya 9 | 10 | """ 11 | from tinytuya import Contrib 12 | import time 13 | 14 | tstatdev = Contrib.ThermostatDevice( 'abcdefghijklmnop123456', '172.28.321.475', '1234567890123abc' ) 15 | 16 | ## we do not need to set persistant or v3.3 as ThermostatDevice() does that for us 17 | 18 | data = tstatdev.status() 19 | print('Device status: %r' % data) 20 | 21 | print(" > Begin Monitor Loop <") 22 | 23 | # the thermostat will close the connection if it doesn't get a heartbeat message every ~28 seconds, so make sure to ping it. 24 | # every 9 seconds, or roughly 3x that limit, is a good number to make sure we don't miss it due to received messages resetting the socket timeout 25 | pingtime = time.time() + 9 26 | 27 | show_all_attribs = True 28 | 29 | while(True): 30 | if( pingtime <= time.time() ): 31 | tstatdev.sendPing() 32 | pingtime = time.time() + 9 33 | 34 | data = tstatdev.receive() 35 | 36 | if data: 37 | if show_all_attribs: 38 | show_all_attribs = False 39 | print( 'Data:', data ) 40 | print( '' ) 41 | print( 'All attribs:', dict(tstatdev) ) 42 | print( '' ) 43 | 44 | if tstatdev.isSingleSetpoint(): 45 | print( 'Single Setpoint (Mode is "cool" or "heat")' ) 46 | else: 47 | print( 'Dual Setpoints (Mode is "auto")' ) 48 | 49 | print( 'Temperature is degrees C or F:', '°' + tstatdev.getCF().upper() ) 50 | print( '' ) 51 | 52 | ## hexadecimal dump of all sensors in a DPS: 53 | #for s in tstatdev.sensorlists: 54 | # print( 'DPS', s.dps, '=', str(s) ) 55 | 56 | ## Base64 dump of all sensors in a DPS: 57 | #for s in tstatdev.sensorlists: 58 | # print( 'DPS', s.dps, '=', s.b64() ) 59 | 60 | ## display info for every sensor: 61 | for s in tstatdev.sensors(): 62 | ## print the DPS containing the sensor, the sensor ID, name, and temperature 63 | print( 'Sensor: DPS:%s ID:%s Name:"%s" Temperature:%r' % (s.parent_sensorlist.dps, s.id, s.name, s.temperature) ) 64 | 65 | ## dump all data as a hexadecimal string 66 | print( str(s) ) 67 | 68 | if 'changed_sensors' in data and len(data['changed_sensors']) > 0: 69 | for s in data['changed_sensors']: 70 | print( 'Sensor Changed! DPS:%s ID:%s Name:"%s" Changed:%r' % (s.parent_sensorlist.dps, s.id, s.name, s.changed) ) 71 | #print(repr(s)) 72 | #print(vars(s)) 73 | 74 | for changed in s.changed: 75 | print( 'Changed:', repr(changed), 'New Value:', getattr( s, changed ) ) 76 | 77 | if( 'sensor_added' in s.changed ): 78 | print( 'New sensor was added!' ) 79 | #print(repr(s.parent_sensorlist)) 80 | #print(str(s)) 81 | 82 | if( 'sensor_added' in s.changed and s.id == '01234567' ): 83 | print('Changing data for sensor', s.id) 84 | 85 | ## by default every change will be sent immediately. if multiple values are to be changed, it is much faster 86 | ## to call s.delayUpdates() first, make the changes, and then call s.sendUpdates() to send them 87 | s.delayUpdates( ) 88 | 89 | ## make some changes 90 | #s.setName( 'Bedroom Sensor 1' ) 91 | s.setEnabled( True ) 92 | #s.setEnabled( False ) 93 | #s.setOccupied( True ) 94 | s.setParticipation( 'wake', True ) 95 | #s.setParticipation( 'sleep', True ) 96 | #s.setParticipation( 0x0F ) 97 | #s.setUnknown2( 0x0A ) 98 | 99 | ## send the queued changes 100 | s.sendUpdates( ) 101 | 102 | show_all_attribs = True 103 | 104 | if 'name' in s.changed: 105 | print( 'Sensor was renamed! New name:', s.name ) 106 | 107 | if 'changed' in data and len(data['changed']) > 0: 108 | print( 'Changed:', data['changed'] ) 109 | for c in data['changed']: 110 | print( 'Changed:', repr(c), 'New Value:', getattr( tstatdev, c ) ) 111 | 112 | if 'cooling_setpoint_f' in data['changed']: 113 | if tstatdev.mode != 'heat' and tstatdev.cooling_setpoint_f < 65: 114 | print( 'Cooling setpoint was set below 65, increasing to 72' ) 115 | tstatdev.setCoolSetpoint( 72 ) 116 | 117 | if 'system' in data['changed'] and tstatdev.system == 'coolfanon': 118 | print( 'System now cooling to', tstatdev.cooling_setpoint_f ) 119 | -------------------------------------------------------------------------------- /examples/bulb.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Example 2 | # -*- coding: utf-8 -*- 3 | """ 4 | TinyTuya - Smart Bulb RGB Test 5 | 6 | Author: Jason A. Cox 7 | For more information see https://github.com/jasonacox/tinytuya 8 | 9 | """ 10 | import tinytuya 11 | import time 12 | import os 13 | import random 14 | 15 | #tinytuya.set_debug() 16 | 17 | DEVICEID = "01234567891234567890" 18 | DEVICEIP = "Auto" # Will try to discover the bulb on the network 19 | DEVICEKEY = "" # Leave blank to read from devices.json 20 | DEVICEVERS = 3.3 # Must be set correctly unless IP=Auto 21 | 22 | # Check for environmental variables and always use those if available 23 | DEVICEID = os.getenv("DEVICEID", DEVICEID) 24 | DEVICEIP = os.getenv("DEVICEIP", DEVICEIP) 25 | DEVICEKEY = os.getenv("DEVICEKEY", DEVICEKEY) 26 | DEVICEVERS = os.getenv("DEVICEVERS", DEVICEVERS) 27 | 28 | print("TinyTuya - Smart Bulb RGB Test [%s]\n" % tinytuya.__version__) 29 | print('TESTING: Device %s at %s with key %s version %s' % 30 | (DEVICEID, DEVICEIP, DEVICEKEY, DEVICEVERS)) 31 | 32 | # Connect to Tuya BulbDevice 33 | d = tinytuya.BulbDevice(DEVICEID, address=DEVICEIP, local_key=DEVICEKEY, version=DEVICEVERS, persist=True) 34 | 35 | if (not DEVICEIP) or (DEVICEIP == 'Auto') or (not DEVICEKEY) or (not DEVICEVERS): 36 | print('Device %s found at %s with key %r version %s' % 37 | (d.id, d.address, d.local_key, d.version)) 38 | 39 | # Show status of device 40 | data = d.status() 41 | print('\nCurrent Status of Bulb: %r' % data) 42 | 43 | # NOTE: the capabilities of the bulb are auto-detected when status() is called. If auto-detection 44 | # fails or you do not call status, you must manually set the capability:DP mapping with either: 45 | #d.set_bulb_type('B') # 'A' 'B' or 'C' 46 | # or 47 | """ 48 | mapping = { 49 | 'switch': 20, # Required 50 | #'mode': None, # Optional 51 | 'brightness': 22, # Required 52 | #'colourtemp': None,# Optional 53 | #'colour': None, # Optional 54 | 'scene': 25, # Optional 55 | 'scene_data': 25, # Optional. Type B prefixes scene data with idx 56 | 'timer': 26, # Optional 57 | 'music': 28, # Optional 58 | 'value_min': 10, # Required. Minimum brightness value 59 | 'value_max': 1000, # Required. Maximum brightness and colourtemp value 60 | 'value_hexformat': 'hsv16', # Required. 'hsv16' or 'rgb8' 61 | } 62 | d.set_bulb_capabilities(mapping) 63 | """ 64 | 65 | # Set to full brightness warm white 66 | # set_white_percentage() will ignore the colour temperature if the bulb does not support it 67 | print('\nWarm White Test') 68 | d.set_white_percentage(100.0, 0.0) # 100% brightness, 0% colour temperature 69 | time.sleep(2) 70 | 71 | # Power Control Test 72 | print('\nPower Control Test') 73 | print(' Turn off lamp') 74 | d.turn_off() 75 | time.sleep(2) 76 | print(' Turn on lamp') 77 | d.turn_on() 78 | time.sleep(2) 79 | 80 | # Dimmer Test 81 | print('\nDimmer Control Test') 82 | for level in range(11): 83 | level *= 10 84 | if not level: level = 1 85 | print(' Level: %d%%' % level) 86 | d.set_brightness_percentage(level) 87 | time.sleep(1) 88 | 89 | # Colortemp Test 90 | # An error JSON will be returned if the bulb does not support colour temperature 91 | if d.bulb_has_capability( d.BULB_FEATURE_COLOURTEMP ): 92 | print('\nColortemp Control Test (Warm to Cool)') 93 | for level in range(11): 94 | print(' Level: %d%%' % (level*10)) 95 | d.set_colourtemp_percentage(level*10) 96 | time.sleep(1) 97 | else: 98 | # set_colourtemp_percentage() will return an error JSON if the bulb does not support colour temperature 99 | print('\nBulb does not have colour temp control, skipping Colortemp Control Test') 100 | 101 | # Flip through colors of rainbow - set_colour(r, g, b): 102 | if d.bulb_has_capability( d.BULB_FEATURE_COLOUR ): 103 | print('\nColor Test - Cycle through rainbow') 104 | rainbow = {"red": [255, 0, 0], "orange": [255, 127, 0], "yellow": [255, 200, 0], 105 | "green": [0, 255, 0], "blue": [0, 0, 255], "indigo": [46, 43, 95], 106 | "violet": [139, 0, 255]} 107 | for x in range(2): 108 | for i in rainbow: 109 | r = rainbow[i][0] 110 | g = rainbow[i][1] 111 | b = rainbow[i][2] 112 | print(' %s (%d,%d,%d)' % (i, r, g, b)) 113 | d.set_colour(r, g, b) 114 | time.sleep(2) 115 | print('') 116 | 117 | # Turn off 118 | d.turn_off() 119 | time.sleep(1) 120 | 121 | # Random Color Test 122 | d.turn_on() 123 | print('\nRandom Color Test') 124 | for x in range(10): 125 | r = random.randint(0, 255) 126 | g = random.randint(0, 255) 127 | b = random.randint(0, 255) 128 | print(' RGB (%d,%d,%d)' % (r, g, b)) 129 | d.set_colour(r, g, b) 130 | time.sleep(2) 131 | else: 132 | print('\nBulb does not do colours, skipping Color Test') 133 | 134 | # Test Modes 135 | if d.bulb_has_capability( d.BULB_FEATURE_MODE ): 136 | print('\nTesting Bulb Modes') 137 | print(' White') 138 | d.set_mode('white') 139 | time.sleep(2) 140 | print(' Colour') 141 | d.set_mode('colour') 142 | time.sleep(2) 143 | print(' Scene') 144 | d.set_mode('scene') 145 | time.sleep(2) 146 | print(' Music') 147 | d.set_mode('music') 148 | time.sleep(2) 149 | else: 150 | print('\nBulb does not support modes, skipping Bulb Mode Test') 151 | 152 | # Done 153 | print('\nDone') 154 | d.turn_off() 155 | -------------------------------------------------------------------------------- /tinytuya/Contrib/AtorchTemperatureControllerDevice.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Outlet Device 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Python module to interface with Tuya ATORCH-Temperature Controller (S1TW) 5 | 6 | Author: Benjamin DUPUIS 7 | For more information see https://github.com/poil 8 | 9 | Local Control Classes 10 | AtorchTemperatureController(...) 11 | See OutletDevice() for constructor arguments 12 | 13 | Functions 14 | AtorchTemperatureControllerDevice: 15 | get_energy_consumption() 16 | get_current() 17 | get_power() 18 | get_get_voltage() 19 | get_state() 20 | get_temp() 21 | Inherited 22 | json = status() # returns json payload 23 | set_version(version) # 3.1 [default] or 3.3 24 | set_socketPersistent(False/True) # False [default] or True 25 | set_socketNODELAY(False/True) # False or True [default] 26 | set_socketRetryLimit(integer) # retry count limit [default 5] 27 | set_socketTimeout(timeout) # set connection timeout in seconds [default 5] 28 | set_dpsUsed(dps_to_request) # add data points (DPS) to request 29 | add_dps_to_request(index) # add data point (DPS) index set to None 30 | set_retry(retry=True) # retry if response payload is truncated 31 | set_status(on, switch=1, nowait) # Set status of switch to 'on' or 'off' (bool) 32 | set_value(index, value, nowait) # Set int value of any index. 33 | heartbeat(nowait) # Send heartbeat to device 34 | updatedps(index=[1], nowait) # Send updatedps command to device 35 | turn_on(switch=1, nowait) # Turn on device / switch # 36 | turn_off(switch=1, nowait) # Turn off 37 | set_timer(num_secs, nowait) # Set timer for num_secs 38 | set_debug(toggle, color) # Activate verbose debugging output 39 | set_sendWait(num_secs) # Time to wait after sending commands before pulling response 40 | detect_available_dps() # Return list of DPS available from device 41 | generate_payload(command, data) # Generate TuyaMessage payload for command with data 42 | send(payload) # Send payload to device (do not wait for response) 43 | receive() 44 | """ 45 | 46 | from ..core import Device 47 | 48 | 49 | class AtorchTemperatureControllerDevice(Device): 50 | """ 51 | Represents a Tuya based Socket 52 | """ 53 | 54 | DPS_MODE = '101' 55 | DPS_CUR_TEMP = '102' 56 | DPS_SWITCH_STATE = '103' 57 | DPS_CURRENT = '108' 58 | DPS_POWER = '109' 59 | DPS_VOLTAGE = '110' 60 | DPS_TEMP_UNIT = '118' 61 | DPS_TOTAL_POWER = '111' # kwh 62 | # TODO 63 | # DPS_HEATING_START_TEMP = 104 64 | # DPS_COOLING_START_TEMP = 105 65 | # DPS_HEATING_STOP_TEMP = 106 66 | # DPS_COOLING_STOP_TEMP = 107 67 | # DPS_POWER_COST = 112 68 | # DPS_OVER_VOLTAGE_LIMIT = 113 69 | # DPS_OVER_INTENSITY_LIMIT = 114 70 | # DPS_OVER_POWER_LIMIT = 115 71 | # DPS_CHILD_LOCK = 116 # bool 72 | # DPS_TEMP_CALIBRATION = 117 73 | # DPS_CURRENT_COST = 125 74 | 75 | def get_energy_consumption(self): 76 | data = self.status() 77 | return {**self.get_current(data), **self.get_power(data), **self.get_voltage(data)} 78 | 79 | def get_current(self, status_data=None): 80 | if status_data is None: 81 | status_data = self.status() 82 | 83 | current = status_data['dps'][self.DPS_CURRENT] 84 | 85 | return {'current_raw': current, 86 | 'current_fmt': str(current) + ' mA', } 87 | 88 | def get_power(self, status_data=None): 89 | if status_data is None: 90 | status_data = self.status() 91 | 92 | power = status_data['dps'][self.DPS_POWER] / 100 93 | 94 | return {'power_raw': power, 95 | 'power_fmt': str(power) + ' W', } 96 | 97 | def get_total_power(self, status_data=None): 98 | if status_data is None: 99 | status_data = self.status() 100 | 101 | power = status_data['dps'][self.DPS_TOTAL_POWER] 102 | 103 | return {'total_power_raw': power, 104 | 'total_power_fmt': str(power) + ' W', } 105 | 106 | def get_voltage(self, status_data=None): 107 | if status_data is None: 108 | status_data = self.status() 109 | 110 | voltage = status_data['dps'][self.DPS_VOLTAGE] / 100 111 | 112 | return {'voltage_raw': voltage, 113 | 'voltage_fmt': str(voltage) + ' V'} 114 | 115 | def get_temp_unit(self, status_data=None): 116 | if status_data is None: 117 | status_data = self.status() 118 | 119 | unit = status_data['dps'][self.DPS_TEMP_UNIT] 120 | return unit 121 | 122 | def get_temp(self, status_data=None): 123 | if status_data is None: 124 | status_data = self.status() 125 | 126 | temp = status_data['dps'][self.DPS_CUR_TEMP] / 10 127 | 128 | return {'cur_temp_raw': temp, 129 | 'cur_temp_fmt': f"{str(temp)} {self.get_temp_unit()}"} 130 | 131 | def get_state(self): 132 | cur_mode = self.status()['dps'][self.DPS_MODE] 133 | if cur_mode == 'socket': 134 | return { 135 | 'mode': cur_mode, 136 | 'status': "on" if self.status()['dps'][self.DPS_SWITCH_STATE] else "off" 137 | } 138 | else: 139 | return {'mode': cur_mode} 140 | -------------------------------------------------------------------------------- /server/web/device_dps.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | TinyTuya API Server - Device Details 5 | 6 | 7 | 8 | 9 | 10 |
11 |

Device Details

12 |
13 |

Device DPS

14 |
15 | 16 | 148 | 149 |
150 |

Back

151 |
152 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /tinytuya/Contrib/DoorbellDevice.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Doorbell Device 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Python module to interface with Tuya WiFi smart devices 5 | 6 | Author: JonesMeUp 7 | Tested: LSC-Bell 8S(AKV300_8M) 8 | Note: Without hack the device can't be used offline. 9 | With hack the DoorbellDevice is useless. 10 | 11 | For more information see https://github.com/jasonacox/tinytuya 12 | https://github.com/jasonacox/tinytuya/issues/162 13 | 14 | Offline Device 15 | This DoorbellDevice works only if the device is online. Most stay 16 | offline to preserve the battery. 17 | 18 | Local Control Classes 19 | DoorbellDevice(...) 20 | See OutletDevice() for constructor arguments 21 | 22 | Functions 23 | DoorbellDevice: 24 | set_basic_indicator(bool): 25 | set_volume(1-10): 26 | set_motion_area(x,y,lenX, lenY) 27 | set_motion_area_switch(bool) 28 | """ 29 | 30 | from ..core import Device 31 | 32 | class DoorbellDevice(Device): 33 | """ 34 | Represents a Tuya based Video-Doorbell. 35 | """ 36 | DPS_2_STATE = { 37 | "101": "basic_indicator", # Boolean (status indicator) 38 | "103": "basic_flip", # Boolean (flip video vertically) 39 | "104": "basic_osd", # Boolean (timestap on video) 40 | "106": "motion_sensitivity", # Enum ["0","1","2"] (low, medium, high) 41 | "108": "basic_nightvision", # Enum ["0","1","2"] (auto, off, on) 42 | "109": "sd_storge", # String ["maxlen":255] (capacity|used|free) e.g: '258048|50176|207872' 43 | "110": "sd_status", # Integer ["min":1,"max": 5,"scale":1,"step":1] 44 | "111": "sd_format", # Boolean 45 | "115": "movement_detect_pic", # Raw 46 | "117": "sd_format_state", # Integer ["min":-20000,"max":20000,"scale":1,"step":1] 47 | "134": "motion_switch", # Boolean (alarm on motion detection) 48 | "136": "doorbell_active", # String ["maxlen":255] (doorbell was pressed) 49 | "150": "record_switch", # Boolean (false = no recording) 50 | "151": "record_mode", # Enum ["1","2"] (1=on event, 2=always) 51 | "154": "doorbell_pic", # Raw (picture of the device) 52 | "155": "doorbell_ring_exist", # Enum ["0","1"] 53 | "156": "chime_ring_tune", # Enum ["1","2","3","4"] 54 | "157": "chime_ring_volume", # Integer ["min":0,"max":100,"scale":1,"step":1] (chime is an extrenal gong [433MhZ]) 55 | "160": "basic_device_volume", # Integer ["min":1,"max": 10,"scale":0,"step":1] 56 | "165": "chime_settings", # Enum ["0","2","3"] 57 | "168": "motion_area_switch", # Boolean (false = use full area) 58 | "169": "motion_area", # String ["maxlen":255] (x, y, xlen, ylen) 59 | "185": "alarm_message", # String 60 | } 61 | DPS_2_FUNC = { 62 | "101": "basic_indicator", # Boolean 63 | "103": "basic_flip", # Boolean 64 | "104": "basic_osd", # Boolean 65 | "106": "motion_sensitivity", # Enum ["0","1","2"] 66 | "108": "basic_nightvision", # Enum ["0","1","2"] 67 | "111": "sd_format", # Boolean 68 | "134": "motion_switch", # Boolean 69 | "150": "record_switch", # Boolean 70 | "151": "record_mode", # Enum ["1","2"] 71 | "155": "doorbell_ring_exist", # Enum ["0","1"] 72 | "156": "chime_ring_tune", # Enum ["1","2","3","4"] 73 | "157": "chime_ring_volume", # Integer ["min":0,"max":100,"scale":1,"step":1] 74 | "160": "basic_device_volume", # Integer ["min":1,"max": 10,"scale":0,"step":1] 75 | "165": "chime_settings", # Enum ["0","2","3"] 76 | "168": "motion_area_switch", # Boolean 77 | "169": "motion_area", # String ["maxlen":255] 78 | } 79 | 80 | def set_basic_indicator(self, val=True, nowait=False): 81 | """ Set the basic incicator """ 82 | self.set_value(101, bool(val), nowait) 83 | 84 | def set_volume(self, vol=10, nowait=False): 85 | """ Set the doorbell volume """ 86 | if vol < 3: 87 | vol = 3 # Nothing to hear below 3 88 | if vol > 10: 89 | vol = 10 90 | self.set_value(160, int(vol), nowait) 91 | 92 | def set_motion_area(self, x=0,y=0,xlen=50, ylen=100, nowait=False): 93 | """ set the area of motion detection [%] """ 94 | if x < 0: x = 0 95 | if y < 0: y = 0 96 | if x > 100: x = 100 97 | if y > 100: y = 100 98 | if xlen < 0: xlen = 0 99 | if ylen < 0: ylen = 0 100 | if xlen > 100: xlen = 100 101 | if ylen > 100: ylen = 100 102 | if x+xlen >100: 103 | x = 25 104 | xlen = 75 105 | if y+ylen >100: 106 | y = 25 107 | ylen = 75 108 | data = '{"num":1,"region0":{"x":'+str(x)+',"y":'+str(y)+',"xlen":'+str(xlen)+',"ylen":'+str(ylen)+'}}' 109 | self.set_value(169, data, nowait) 110 | 111 | def set_motion_area_switch(self, useArea=False, nowait=False): 112 | """ use the area of motion detection on/off """ 113 | self.set_value(168, bool(useArea), nowait) 114 | -------------------------------------------------------------------------------- /tinytuya/Contrib/BlanketDevice.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Outlet Device 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Python module to interface with Tuya Electric Heating Blanket 5 | 6 | Author: Leo Denham (https://github.com/leodenham) 7 | Tested: Goldair Platinum Electric Blanket GPFAEB-Q 8 | 9 | Local Control Classes 10 | BlanketDevice(...) 11 | See OutletDevice() for constructor arguments 12 | 13 | Functions 14 | BlanketDevice: 15 | get_feet_level() 16 | get_body_level() 17 | set_feet_level() 18 | set_body_level() 19 | get_feet_time() 20 | get_body_time() 21 | set_feet_time() 22 | set_body_time() 23 | get_feet_countdown() 24 | get_body_countdown() 25 | 26 | 27 | Inherited 28 | json = status() # returns json payload 29 | set_version(version) # 3.1 [default] or 3.3 30 | set_socketPersistent(False/True) # False [default] or True 31 | set_socketNODELAY(False/True) # False or True [default] 32 | set_socketRetryLimit(integer) # retry count limit [default 5] 33 | set_socketTimeout(timeout) # set connection timeout in seconds [default 5] 34 | set_dpsUsed(dps_to_request) # add data points (DPS) to request 35 | add_dps_to_request(index) # add data point (DPS) index set to None 36 | set_retry(retry=True) # retry if response payload is truncated 37 | set_status(on, switch=1, nowait) # Set status of switch to 'on' or 'off' (bool) 38 | set_value(index, value, nowait) # Set int value of any index. 39 | heartbeat(nowait) # Send heartbeat to device 40 | updatedps(index=[1], nowait) # Send updatedps command to device 41 | turn_on(switch=1, nowait) # Turn on device / switch # 42 | turn_off(switch=1, nowait) # Turn off 43 | set_timer(num_secs, nowait) # Set timer for num_secs 44 | set_debug(toggle, color) # Activate verbose debugging output 45 | set_sendWait(num_secs) # Time to wait after sending commands before pulling response 46 | detect_available_dps() # Return list of DPS available from device 47 | generate_payload(command, data) # Generate TuyaMessage payload for command with data 48 | send(payload) # Send payload to device (do not wait for response) 49 | receive() 50 | """ 51 | 52 | from ..core import Device, error_json, ERR_RANGE 53 | 54 | 55 | class BlanketDevice(Device): 56 | """ 57 | Represents a Tuya based Electric Blanket Device 58 | """ 59 | DPS = 'dps' 60 | DPS_BODY_LEVEL = '14' 61 | DPS_FEET_LEVEL = '15' 62 | DPS_BODY_TIME = '16' 63 | DPS_FEET_TIME = '17' 64 | DPS_BODY_COUNTDOWN = '18' 65 | DPS_FEET_COUNTDOWN = '19' 66 | LEVEL_PREFIX = 'level_' 67 | 68 | def _number_to_level(self, num): 69 | return f'{self.LEVEL_PREFIX}{num+1}' 70 | 71 | def _level_to_number(self, level): 72 | return int(level.split(self.LEVEL_PREFIX)[1]) - 1 73 | 74 | def get_feet_level(self, status_data=None): 75 | if status_data is None: 76 | status_data = self.status() 77 | 78 | current = self._level_to_number(status_data[self.DPS][self.DPS_FEET_LEVEL]) 79 | return current 80 | 81 | def get_body_level(self, status_data=None): 82 | if status_data is None: 83 | status_data = self.status() 84 | 85 | current = self._level_to_number(status_data[self.DPS][self.DPS_BODY_LEVEL]) 86 | return current 87 | 88 | def set_feet_level(self, num): 89 | if (num < 0 or num > 6): 90 | return error_json( 91 | ERR_RANGE, "set_feet_level: The value for the level needs to be between 0 and 6." 92 | ) 93 | return self.set_value(self.DPS_FEET_LEVEL, self._number_to_level(num)) 94 | 95 | def set_body_level(self, num): 96 | if (num < 0 or num > 6): 97 | return error_json( 98 | ERR_RANGE, "set_body_level: The value for the level needs to be between 0 and 6." 99 | ) 100 | return self.set_value(self.DPS_BODY_LEVEL, self._number_to_level(num)) 101 | 102 | def get_feet_time(self, status_data=None): 103 | if status_data is None: 104 | status_data = self.status() 105 | 106 | current = status_data[self.DPS][self.DPS_FEET_TIME] 107 | return current.replace('h', '') 108 | 109 | def get_body_time(self, status_data=None): 110 | if status_data is None: 111 | status_data = self.status() 112 | 113 | current = status_data[self.DPS][self.DPS_BODY_TIME] 114 | return current.replace('h', '') 115 | 116 | def set_feet_time(self, num): 117 | if (num < 1 or num > 12): 118 | return error_json( 119 | ERR_RANGE, "set_feet_time: The value for the time needs to be between 1 and 12." 120 | ) 121 | return self.set_value(self.DPS_FEET_TIME, f"{num}h") 122 | 123 | def set_body_time(self, num): 124 | if (num < 1 or num > 12): 125 | return error_json( 126 | ERR_RANGE, "set_body_time: The value for the time needs to be between 1 and 12." 127 | ) 128 | return self.set_value(self.DPS_BODY_TIME, f"{num}h") 129 | 130 | def get_feet_countdown(self, status_data=None): 131 | if status_data is None: 132 | status_data = self.status() 133 | 134 | current = status_data[self.DPS][self.DPS_FEET_COUNTDOWN] 135 | return current 136 | 137 | def get_body_countdown(self, status_data=None): 138 | if status_data is None: 139 | status_data = self.status() 140 | 141 | current = status_data[self.DPS][self.DPS_BODY_COUNTDOWN] 142 | return current 143 | 144 | -------------------------------------------------------------------------------- /examples/Contrib/IRRemoteControlDevice-example.py: -------------------------------------------------------------------------------- 1 | 2 | # TinyTuya IRRemoteControlDevice Example 3 | # -*- coding: utf-8 -*- 4 | """ 5 | Example script using the community-contributed Python module for Tuya WiFi smart universal remote control simulators 6 | 7 | Author: Alexey 'Cluster' Avdyukhin (https://github.com/clusterm) 8 | Rewritten by: uzlonewolf (https://github.com/uzlonewolf) 9 | For more information see https://github.com/jasonacox/tinytuya 10 | 11 | """ 12 | import sys 13 | import tinytuya 14 | from tinytuya import Contrib 15 | from time import sleep 16 | 17 | #tinytuya.set_debug(toggle=True, color=True) 18 | 19 | 20 | 21 | 22 | # parsing and converting between data formats 23 | 24 | 25 | # discrete on/off codes for Samsung in Pronto format 26 | pronto_samsung_on = '0000 006D 0000 0022 00AC 00AC 0015 0040 0015 0040 0015 0040 0015 0015 0015 0015 0015 0015 0015 0015 0015 0015 0015 0040 0015 0040 0015 0040 0015 0015 0015 0015 0015 0015 0015 0015 0015 0015 0015 0040 0015 0015 0015 0015 0015 0040 0015 0040 0015 0015 0015 0015 0015 0040 0015 0015 0015 0040 0015 0040 0015 0015 0015 0015 0015 0040 0015 0040 0015 0015 0015 0689' 27 | pronto_samsung_off = '0000 006D 0000 0022 00AC 00AC 0015 0040 0015 0040 0015 0040 0015 0015 0015 0015 0015 0015 0015 0015 0015 0015 0015 0040 0015 0040 0015 0040 0015 0015 0015 0015 0015 0015 0015 0015 0015 0015 0015 0015 0015 0015 0015 0015 0015 0040 0015 0040 0015 0015 0015 0015 0015 0040 0015 0040 0015 0040 0015 0040 0015 0015 0015 0015 0015 0040 0015 0040 0015 0015 0015 0689' 28 | 29 | # convert the Pronto format into pulses 30 | pulses_samsung_on = Contrib.IRRemoteControlDevice.pronto_to_pulses( pronto_samsung_on ) 31 | pulses_samsung_off = Contrib.IRRemoteControlDevice.pronto_to_pulses( pronto_samsung_off ) 32 | 33 | # decode the pulses as Samsung format (similar to NEC but with a half-width start burst) 34 | # there may be more than one code in the data stream, so this returns a list of codes 35 | samsung_on_code = Contrib.IRRemoteControlDevice.pulses_to_samsung( pulses_samsung_on ) 36 | samsung_off_code = Contrib.IRRemoteControlDevice.pulses_to_samsung( pulses_samsung_off ) 37 | 38 | # print only the first code 39 | print( 'Samsung on code:', samsung_on_code[0] ) 40 | # Samsung on code: {'type': 'samsung', 'uint32': 3772815718, 'address': 7, 'data': 153, 'hex': 'E0E09966'} 41 | 42 | print( 'Samsung off code:', samsung_off_code[0] ) 43 | # Samsung off code: {'type': 'samsung', 'uint32': 3772783078, 'address': 7, 'data': 152, 'hex': 'E0E019E6'} 44 | 45 | 46 | 47 | 48 | 49 | 50 | # discrete on/off codes for LG 51 | hex_lg_on = 0x20DF23DC 52 | hex_lg_off = 0x20DFA35C 53 | 54 | # convert the 32-bit integers into a stream of pulses 55 | pulses_lg_on = Contrib.IRRemoteControlDevice.nec_to_pulses( hex_lg_on ) 56 | pulses_lg_off = Contrib.IRRemoteControlDevice.nec_to_pulses( hex_lg_off ) 57 | 58 | # decode the pulses to verify and print them like the above Samsung 59 | lg_on_code = Contrib.IRRemoteControlDevice.pulses_to_nec( pulses_lg_on ) 60 | print( 'LG on code:', lg_on_code[0] ) 61 | # LG on code: {'type': 'nec', 'uint32': 551494620, 'address': 4, 'data': 196, 'hex': '20DF23DC'} 62 | 63 | lg_off_code = Contrib.IRRemoteControlDevice.pulses_to_nec( pulses_lg_off ) 64 | print( 'LG off code:', lg_off_code[0] ) 65 | # LG off code: {'type': 'nec', 'uint32': 551527260, 'address': 4, 'data': 197, 'hex': '20DFA35C'} 66 | 67 | 68 | 69 | 70 | 71 | # both Pronto codes and pulses can also be turned into head/key format 72 | # Pronto will have the correct frequency in the data 73 | headkey = Contrib.IRRemoteControlDevice.pronto_to_head_key( pronto_samsung_on ) 74 | if headkey: 75 | head, key = headkey 76 | # but the pulses frequency needs to be specified manually if it is not 38 kHz 77 | headkey = Contrib.IRRemoteControlDevice.pulses_to_head_key( pulses_samsung_on, freq=38 ) 78 | if headkey: 79 | head, key = headkey 80 | 81 | 82 | 83 | 84 | # learned codes can also be converted 85 | pulses = Contrib.IRRemoteControlDevice.base64_to_pulses('IyOvEToCZQI5AkoCOgJNAjYCTwI4AlACNQJMAjkCTQI2ApsGSwKZBkkClwZMAp8GLALLBhgC0wYRAtMGEwLRBhMCbgIdAmkCGwLKBhsCagIaAsoGGgJzAhACbwIWAnICFAJvAh0CxgYdAmoCFwLMBhoCcAIUAtAGFALRBhQC0QYUAtAGFQKXnBgjCAkXAiDL') 86 | # default frequency is 38 kHz 87 | headkey = Contrib.IRRemoteControlDevice.pulses_to_head_key( pulses ) 88 | if headkey: 89 | head, key = headkey 90 | 91 | 92 | 93 | 94 | # now onto talking to the device! 95 | 96 | 97 | # create the device. this will connect to it to try and determine which DPS it uses 98 | ir = Contrib.IRRemoteControlDevice( 'abcdefghijklmnop123456', '172.28.321.475', '1234567890123abc', persist=True ) 99 | 100 | 101 | print( 'Turning the Samsung tv on with pulses' ) 102 | ir.send_button( ir.pulses_to_base64( pulses_samsung_on ) ) 103 | sleep(0.5) 104 | print( 'Turning the LG tv on with pulses' ) 105 | ir.send_button( ir.pulses_to_base64( pulses_lg_on ) ) 106 | sleep(0.5) 107 | 108 | 109 | print( 'Turning the Samsung tv off with head/key' ) 110 | head, key = Contrib.IRRemoteControlDevice.pronto_to_head_key( pronto_samsung_off ) 111 | ir.send_key( head, key ) 112 | sleep(0.5) 113 | print( 'Turning the LG tv off with head/key' ) 114 | head, key = Contrib.IRRemoteControlDevice.pulses_to_head_key( pulses_lg_off ) 115 | ir.send_key( head, key ) 116 | sleep(0.5) 117 | 118 | 119 | 120 | 121 | # learn a new remote 122 | print("Press button on your remote control") 123 | button = ir.receive_button(timeout=15) 124 | if (button == None): 125 | print("Timeout, button code is not received") 126 | sys.exit(1) 127 | 128 | print("Received button:", button) 129 | pulses = ir.base64_to_pulses(button) 130 | print( Contrib.IRRemoteControlDevice.print_pulses( pulses ) ) 131 | headkey = Contrib.IRRemoteControlDevice.pulses_to_head_key( pulses ) 132 | if headkey: 133 | head, key = headkey 134 | print( 'Head:', head ) 135 | print( 'Key:', key ) 136 | 137 | for i in range(10): 138 | print("Simulating button press...") 139 | # either works 140 | #ir.send_button(button) 141 | ir.send_key( head, key ) 142 | sleep(1) 143 | -------------------------------------------------------------------------------- /tinytuya/Contrib/ClimateDevice.py: -------------------------------------------------------------------------------- 1 | from tinytuya.core import Device 2 | 3 | """ 4 | Python module to interface with Tuya Portable Air Conditioner devices 5 | 6 | Local Control Classes 7 | ClimateDevice(..., version=3.3) 8 | This class uses a default version of 3.3 9 | See OutletDevice() for the other constructor arguments 10 | 11 | Functions 12 | ClimateDevice: 13 | status_json() 14 | get_room_temperature() 15 | get_target_temperature() 16 | set_target_temperature() 17 | get_operating_mode() 18 | set_operating_mode() 19 | get_fan_speed() 20 | set_fan_speed() 21 | get_current_state() 22 | get_timer() 23 | set_timer() 24 | get_temperature_unit() 25 | set_temperature_unit() 26 | Inherited 27 | json = status() # returns json payload 28 | set_version(version) # 3.1 [default] or 3.3 29 | set_socketPersistent(False/True) # False [default] or True 30 | set_socketNODELAY(False/True) # False or True [default] 31 | set_socketRetryLimit(integer) # retry count limit [default 5] 32 | set_socketTimeout(timeout) # set connection timeout in seconds [default 5] 33 | set_dpsUsed(dps_to_request) # add data points (DPS) to request 34 | add_dps_to_request(index) # add data point (DPS) index set to None 35 | set_retry(retry=True) # retry if response payload is truncated 36 | set_status(on, switch=1, nowait) # Set status of switch to 'on' or 'off' (bool) 37 | set_value(index, value, nowait) # Set int value of any index. 38 | heartbeat(nowait) # Send heartbeat to device 39 | updatedps(index=[1], nowait) # Send updatedps command to device 40 | turn_on(switch=1, nowait) # Turn on device / switch # 41 | turn_off(switch=1, nowait) # Turn off 42 | set_timer(num_secs, nowait) # Set timer for num_secs 43 | set_debug(toggle, color) # Activate verbose debugging output 44 | set_sendWait(num_secs) # Time to wait after sending commands before pulling response 45 | detect_available_dps() # Return list of DPS available from device 46 | generate_payload(command, data) # Generate TuyaMessage payload for command with data 47 | send(payload) # Send payload to device (do not wait for response) 48 | receive() 49 | """ 50 | 51 | 52 | class ClimateDevice(Device): 53 | """ 54 | Represents a Tuya based Air Conditioner 55 | """ 56 | 57 | DPS_POWER = "1" 58 | DPS_SET_TEMP = "2" 59 | DPS_CUR_TEMP = "3" 60 | DPS_MODE = "4" 61 | DPS_FAN = "5" 62 | DPS_TEMP_UNIT = "19" 63 | DPS_TIMER = "22" 64 | DPS_SLEEP_PRESET = "25" 65 | DPS_SWING = "30" 66 | DPS_STATE = "101" 67 | 68 | def __init__(self, *args, **kwargs): 69 | # set the default version to 3.3 as there are no 3.1 devices 70 | if 'version' not in kwargs or not kwargs['version']: 71 | kwargs['version'] = 3.3 72 | super(ClimateDevice, self).__init__(*args, **kwargs) 73 | 74 | def status_json(self): 75 | """Wrapper around status() that replace DPS indices with human readable labels.""" 76 | status = self.status()["dps"] 77 | return { 78 | "Power On": status[self.DPS_POWER], 79 | "Set temperature": status[self.DPS_SET_TEMP], 80 | "Current temperature": status[self.DPS_CUR_TEMP], 81 | "Fan speed": status[self.DPS_FAN], 82 | "Temperature unit": status[self.DPS_TEMP_UNIT], 83 | "Sleep preset On": status[self.DPS_SLEEP_PRESET], 84 | "Swing On": status[self.DPS_SWING], 85 | "Operating mode": status[self.DPS_STATE], 86 | "Timer left": status[self.DPS_TIMER], 87 | } 88 | 89 | def get_room_temperature(self): 90 | status = self.status()["dps"] 91 | return status[self.DPS_CUR_TEMP] 92 | 93 | def get_target_temperature(self): 94 | status = self.status()["dps"] 95 | return status[self.DPS_SET_TEMP] 96 | 97 | def set_target_temperature(self, t): 98 | def is_float(f): 99 | try: 100 | float(f) 101 | return True 102 | except ValueError: 103 | return False 104 | 105 | # non numeric values can confuse the unit 106 | if not is_float(t): 107 | return 108 | 109 | self.set_value(self.DPS_SET_TEMP, t) 110 | 111 | def get_operating_mode(self): 112 | status = self.status()["dps"] 113 | return status[self.DPS_MODE] 114 | 115 | def set_operating_mode(self, mode): 116 | if mode not in ("cold", "hot", "dehumidify"): 117 | return 118 | self.set_value(self.DPS_MODE, mode) 119 | 120 | def get_fan_speed(self): 121 | status = self.status()["dps"] 122 | return status[self.DPS_FAN] 123 | 124 | def set_fan_speed(self, value): 125 | if value not in ("auto", "low", "middle", "high"): 126 | return 127 | self.set_value(self.DPS_FAN, value) 128 | 129 | def get_current_state(self): 130 | status = self.status()["dps"] 131 | return "On" if status[self.DPS_POWER] else "Off" 132 | 133 | def get_timer(self): 134 | status = self.status()["dps"] 135 | return status[self.DPS_TIMER] 136 | 137 | def set_timer(self, delay): 138 | if delay < 0 or delay > 24: 139 | return 140 | self.set_value(self.DPS_TIMER, delay) 141 | 142 | def get_temperature_unit(self): 143 | status = self.status()["dps"] 144 | return status[self.DPS_TEMP_UNIT] 145 | 146 | def set_temperature_unit(self, unit): 147 | # no matter what the unit is, the Fral Supercool 19.2SC report temperatures in Celsius. The unit seems to influence only the display on the unit. 148 | self.set_value(self.DPS_TEMP_UNIT, unit) 149 | -------------------------------------------------------------------------------- /examples/threading.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Example 2 | # -*- coding: utf-8 -*- 3 | """ 4 | TinyTuya - Multi-threaded Example 5 | 6 | This demonstrates how to use threading to listen for status updates from multiple 7 | Tuya devices. 8 | 9 | Setup: 10 | Set the config for each device and the script will create a thread for each device 11 | to listen for status updates. The main thread will continue to run and can be used 12 | to send commands to the devices. 13 | 14 | Author: Jason A. Cox 15 | For more information see https://github.com/jasonacox/tinytuya 16 | 17 | """ 18 | 19 | import threading 20 | import time 21 | import tinytuya 22 | 23 | # Define the devices to control 24 | config = { 25 | "TuyaDevices": [ 26 | { 27 | "Address": "192.168.1.10", 28 | "Device ID": "00112233445566778899", 29 | "Local Key": "1234567890123abc", 30 | "Version": "3.3", 31 | }, 32 | { 33 | "Address": "192.168.1.11", 34 | "Device ID": "10112233445566778899", 35 | "Local Key": "1234567890123abc", 36 | "Version": "3.3", 37 | }, 38 | { 39 | "Address": "192.168.1.12", 40 | "Device ID": "20112233445566778899", 41 | "Local Key": "1234567890123abc", 42 | "Version": "3.3", 43 | }, 44 | { 45 | "Address": "192.168.1.13", 46 | "Device ID": "30112233445566778899", 47 | "Local Key": "1234567890123abc", 48 | "Version": "3.3", 49 | } 50 | ] 51 | } 52 | 53 | 54 | # Settings 55 | TTL_HEARTBEAT = 12 # Time in seconds between heartbeats 56 | 57 | # Create array, devices, that is an array of tinytuya.Device objects 58 | devices = [] 59 | for i in config["TuyaDevices"]: 60 | d = tinytuya.Device(i["Device ID"], i["Address"], i["Local Key"], version=i["Version"]) 61 | devices.append(d) # Add the device to the devices array 62 | 63 | # Function to listen for status updates from each device 64 | def getDeviceStatuses(): 65 | global devices 66 | global statuses 67 | 68 | def listen_for_status_updates(device, index): 69 | """ 70 | Thread function to continuously listen for status updates and send heartbeats. 71 | """ 72 | # Enable persistent connection to the device 73 | def reconnect(): 74 | time.sleep(5) # Cool-down before reconnecting 75 | try: 76 | print(f"Reconnecting to {device.id}...") 77 | device.set_socketPersistent(True) 78 | initial_status = device.status() 79 | print(f"Reconnected and got status from {device.id}: {initial_status}") 80 | statuses[index] = {"id": device.id, "status": initial_status["dps"]} 81 | return True 82 | except Exception as e: 83 | print(f"Failed to reconnect to {device.id}: {e}") 84 | return False 85 | 86 | try: 87 | # Call status() once to establish connection and get initial status 88 | device.set_socketPersistent(True) 89 | initial_status = device.status() 90 | print(f"INITIAL status from {device.id}: {initial_status}") 91 | statuses[index] = {"id": device.id, "status": initial_status["dps"]} 92 | except Exception as e: 93 | print(f"Error getting initial status from {device.id}: {e}") 94 | statuses[index] = {"id": device.id, "status": "Disconnected"} 95 | return 96 | 97 | # Variables to track the last heartbeat 98 | last_heartbeat_time = time.time() 99 | 100 | # Infinite loop to listen for status updates 101 | while True: 102 | try: 103 | # Send a heartbeat every 5 seconds 104 | if time.time() - last_heartbeat_time >= TTL_HEARTBEAT: 105 | try: 106 | device.heartbeat() 107 | print(f"Heartbeat sent to {device.id}") 108 | last_heartbeat_time = time.time() 109 | except Exception as hb_error: 110 | print(f"Failed to send heartbeat to {device.id}: {hb_error}") 111 | # Try to reconnect if the heartbeat fails 112 | if not reconnect(): 113 | statuses[index]["status"] = "Disconnected" 114 | break # Exit the loop if reconnection fails 115 | 116 | # Listen for updates from the device 117 | updated_status = device.receive() 118 | 119 | if updated_status: 120 | print(f"UPDATE status from {device.id}: {updated_status}") 121 | # We may only get one DPS, so just update that one item 122 | if "dps" in updated_status: 123 | for key in updated_status["dps"]: 124 | statuses[index]["status"][key] = updated_status["dps"][key] 125 | print(f" - Updated status for {device.id} DPS {key} to {updated_status['dps'][key]}") 126 | 127 | # Small delay to avoid tight loops 128 | time.sleep(0.1) 129 | 130 | except Exception as e: 131 | print(f"Error receiving status from {device.id}: {e}") 132 | statuses[index]["status"] = "Disconnected" 133 | if not reconnect(): 134 | break # Exit the loop if reconnection fails 135 | 136 | threads = [] 137 | 138 | # Create and start a thread for each device 139 | for index, device in enumerate(devices): 140 | print(f"Starting thread for device {device.id}") 141 | thread = threading.Thread(target=listen_for_status_updates, args=(device, index)) 142 | thread.daemon = True # Daemon threads exit with the main program 143 | threads.append(thread) 144 | thread.start() 145 | 146 | # Example usage 147 | statuses = [None] * len(devices) # Initialize statuses list to hold results for each device 148 | 149 | getDeviceStatuses() 150 | 151 | # Optionally, keep the main program running indefinitely 152 | while True: 153 | time.sleep(1) # Keep the main thread alive 154 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # TinyTuya API Server 2 | 3 | ![Docker Pulls](https://img.shields.io/docker/pulls/jasonacox/tinytuya) 4 | 5 | The TinyTuya API Server provides a central service to access all your Tuya devices on your network. It continually listens for Tuya UDP discovery packets and updates the database of active devices. It uses `devices.json` to poll the devices for status or change their state. 6 | 7 | **BETA**: This is under active development. Please reach out if you have suggestions or wish to contribute~ 8 | 9 | API Functions - The server listens for GET requests on local port 8888: 10 | 11 | ``` 12 | /help - List all available commands 13 | /devices - List all devices discovered with metadata 14 | /device/{DeviceID}|{DeviceName} - List specific device metadata 15 | /numdevices - List current number of devices discovered 16 | /status/{DeviceID}|{DeviceName} - List current device status 17 | /set/{DeviceID}|{DeviceName}/{Key}/{Value} - Set DPS {Key} with {Value} 18 | /turnon/{DeviceID}/{SwitchNo} - Turn on device, optional {SwtichNo} 19 | /turnoff/{DeviceID}/{SwitchNo} - Turn off device, optional {SwtichNo} 20 | /delayedoff/{DeviceID}|{DeviceName}/{SwitchNo}/{Seconds} 21 | - Turn off device with a delay, optional {SwitchNo}/{Delay} 22 | /sync - Fetches the device list and local keys from the Tuya Cloud API 23 | /cloudconfig/{apiKey}/{apiSecret}/{apiRegion}/{apiDeviceID} 24 | - Sets the Tuya Cloud API login info 25 | /offline - List of registered devices that are offline 26 | ``` 27 | 28 | Note! If you use {DeviceName} instead of {DeviceID}, make sure your Device Names are absolutely unique! Otherwise you will get funny results. 29 | 30 | ## Quick Start 31 | 32 | This folder contains the `server.py` script that runs a simple python based webserver that makes the TinyTuya API calls. Make sure the `device.json` file is the same directory where you start the server. 33 | 34 | ```bash 35 | # Start Server - use Control-C to Stop 36 | python3 server.py 37 | 38 | # Start Server in Debug Mode 39 | python3 server.py -d 40 | ``` 41 | 42 | ``` 43 | TinyTuya (Server) [1.10.0t4] 44 | 45 | [Loaded devices.json - 39 devices] 46 | 47 | Starting threads... 48 | - API and UI Endpoint on http://localhost:8888 49 | ``` 50 | 51 | ## Docker Container 52 | 53 | 1. Run the Server as a Docker Container listening on port 8888. Make sure your Tinytuya `devices.json` file is located in the directory where you start the container. Set `HOST` to the primary IP address of your docker host, otherwise a request to Force Scan the network will scan every possible docker IP network on your host. 54 | 55 | ```bash 56 | docker run \ 57 | -d \ 58 | -p 8888:8888 \ 59 | -p 6666:6666/udp \ 60 | -p 6667:6667/udp \ 61 | -p 7000:7000/udp \ 62 | --network host \ 63 | -e DEBUGMODE='no' \ 64 | -e HOST='192.168.0.100' \ 65 | -v $PWD/devices.json:/app/devices.json \ 66 | -v $PWD/tinytuya.json:/app/tinytuya.json \ 67 | --name tinytuya \ 68 | --restart unless-stopped \ 69 | jasonacox/tinytuya 70 | ``` 71 | 72 | 2. Test the Server 73 | 74 | You can load the Web Interface to view all your devices: http://localhost:8888/ 75 | 76 | Additionally you can use the API server to poll or mange your Tuya devices with simple web service calls: 77 | 78 | ```bash 79 | # Get Tuya Device Information 80 | curl -i http://localhost:8888/numdevices 81 | curl -i http://localhost:8888/devices 82 | curl -i http://localhost:8888/device/{deviceID} 83 | curl -i http://localhost:8888/status/{deviceID} 84 | 85 | # Command Tuya Devices 86 | curl -i http://localhost:8888/turnon/{deviceID} 87 | curl -i http://localhost:8888/turnoff/{deviceID} 88 | curl -i http://localhost:8888/set/{deviceID}/{key}/{value} 89 | ``` 90 | 91 | ### Troubleshooting Help 92 | 93 | Check the logs. If you see python errors, make sure you entered your credentials correctly in the `server.py` file. If you didn't, edit that file and restart docker: 94 | 95 | ```bash 96 | # See the logs 97 | docker logs tinytuya 98 | 99 | # Stop the server 100 | docker stop tinytuya 101 | 102 | # Start the server 103 | docker start tinytuya 104 | ``` 105 | 106 | ## Control Panel 107 | 108 | The UI at http://localhost:8888 allows you to view and control the devices. 109 | 110 | ![image](https://github.com/jasonacox/tinytuya/assets/836718/e00a1f9a-48e2-400c-afa1-7a81799efa89) 111 | 112 | ![image](https://user-images.githubusercontent.com/836718/227736057-e5392c13-554f-457e-9082-43c4d41a98ed.png) 113 | 114 | ## Release Notes 115 | 116 | ### p15 - Cross-Platform Memory Stats 117 | 118 | * Switched to using the `psutil` library for memory usage stats, making server.py fully cross-platform (Windows, macOS, Linux). Fixes issue #634. 119 | * Memory usage is now reported on all platforms if `psutil` is installed; if not, the field is set to None. 120 | * Added requirements to documentation and header: `pip install psutil tinytuya colorama requests`. 121 | 122 | ### p14 - Recovery Logic 123 | 124 | * Add main loop logic to try to recover when exception occurs. 125 | 126 | ### p12 - Force Scan 127 | 128 | * Added "Force Scan" button to cause server to run a network scan for devices not broadcasting. 129 | * Minor updates to UI for a cleaner title and footer to accommodate button. 130 | * Added logic to allow settings via environmental variables. 131 | * Add broadcast request to local network for 3.5 devices. 132 | * Fix bug with cloud sync refresh losing device mappings. 133 | * Added "Cloud Sync" button to poll cloud for updated device data. 134 | 135 | ### t11 - Minimize Container 136 | 137 | * Reduce size of Docker container by removing rust build and using python:3.12-bookworm. 138 | * Add signal handler for cleaner shutdown handling for `docker stop`. 139 | 140 | ### t10 - Remove Import 141 | 142 | * Remove unused imports for Crypto. 143 | 144 | ### t9 - DeviceName Addition 145 | 146 | * Use {DeviceName} instead of {DeviceID} alternatively for API commands 147 | 148 | ### t8 - Mappings 149 | 150 | * Mapping for DP IDs in https://github.com/jasonacox/tinytuya/pull/353. 151 | 152 | ### t7 - Add Control by Name 153 | 154 | * Use {`DeviceName`} in addition to {`DeviceID`} for API commands by @mschlenstedt in https://github.com/jasonacox/tinytuya/pull/352 155 | 156 | ```bash 157 | # by DeviceID 158 | $ curl http://localhost:8888/status/xxxxxxxxxxxxxxxxxx01 159 | {"devId": "xxxxxxxxxxxxxxxxxx01", "dps": {"1": true, "9": 0}} 160 | 161 | # by DeviceName 162 | $ curl http://localhost:8888/status/Kitchen%20Light 163 | {"devId": "xxxxxxxxxxxxxxxxxx01", "dps": {"1": true, "9": 0}} 164 | $ curl http://localhost:8888/status/SmartBulb 165 | {"devId": "xxxxxxxxxxxxxxxxxx02", "dps": {"20": true, "21": "white", "22": 1000, "24": "000003e803e8", "25":"07464602000003e803e800000000464602007803e803e80000000046460200f003e803e800000000464602003d03e803e80000000046460200ae03e803e800000000464602011303e803e800000000", "26": 0}} 166 | ``` 167 | -------------------------------------------------------------------------------- /tools/broadcast-relay.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # PYTHON_ARGCOMPLETE_OK 4 | 5 | """ 6 | A program that listens for broadcast packets from Tuya devices and sends them via unicast to App clients. 7 | Useful to make the app work on broadcast-blocking WiFi networks. 8 | 9 | Written by uzlonewolf (https://github.com/uzlonewolf) for the TinyTuya project https://github.com/jasonacox/tinytuya 10 | 11 | Call with "-h" for options. 12 | """ 13 | 14 | BROADCASTTIME = 6 # How often to broadcast to port 7000 to get v3.5 devices to send us their info 15 | 16 | import json 17 | import logging 18 | import socket 19 | import select 20 | import time 21 | import traceback 22 | import argparse 23 | 24 | from tinytuya import decrypt_udp, UDPPORT, UDPPORTS, UDPPORTAPP 25 | from tinytuya.scanner import send_discovery_request 26 | 27 | try: 28 | import argcomplete 29 | HAVE_ARGCOMPLETE = True 30 | except: 31 | HAVE_ARGCOMPLETE = False 32 | 33 | if __name__ == '__main__': 34 | log = logging.getLogger( 'broadcast-relay' ) 35 | else: 36 | log = logging.getLogger(__name__) 37 | 38 | def relay( args ): 39 | log.info( 'Starting Relay' ) 40 | 41 | send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 42 | #send_sock.bind(("", 0)) 43 | 44 | # Enable UDP listening broadcasting mode on UDP port 6666 - 3.1 Devices 45 | client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 46 | client.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 47 | try: 48 | client.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) 49 | except AttributeError: 50 | # SO_REUSEPORT not available 51 | pass 52 | client.bind(("", UDPPORT)) 53 | 54 | # Enable UDP listening broadcasting mode on encrypted UDP port 6667 - 3.2-3.5 Devices 55 | clients = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 56 | clients.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 57 | try: 58 | clients.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) 59 | except AttributeError: 60 | # SO_REUSEPORT not available 61 | pass 62 | clients.bind(("", UDPPORTS)) 63 | 64 | # Enable UDP listening broadcasting mode on encrypted UDP port 7000 - App 65 | clientapp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 66 | clientapp.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 67 | try: 68 | clientapp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) 69 | except AttributeError: 70 | # SO_REUSEPORT not available 71 | pass 72 | clientapp.bind(("", UDPPORTAPP)) 73 | 74 | broadcasted_apps = {} 75 | our_broadcasts = {} 76 | read_socks = [] 77 | write_socks = [] 78 | broadcast_query_timer = 0 79 | 80 | while True: 81 | read_socks = [client, clients, clientapp] 82 | write_socks = [] 83 | 84 | try: 85 | rd, wr, _ = select.select( read_socks, write_socks, [] ) 86 | except KeyboardInterrupt as err: 87 | log.warning("**User Break**") 88 | break 89 | 90 | for sock in rd: 91 | data, addr = sock.recvfrom(4048) 92 | ip = addr[0] 93 | result = b'' 94 | 95 | if sock is clientapp: 96 | tgt_port = UDPPORTAPP 97 | result = None 98 | 99 | if ip in our_broadcasts: 100 | log.debug( 'Ignoring our own broadcast: %r', ip ) 101 | continue 102 | 103 | try: 104 | result = decrypt_udp( data ) 105 | result = json.loads(result) 106 | except: 107 | log.warning( '* Invalid UDP Packet from %r port %r: %r (%r)', ip, tgt_port, result, data, exc_info=True ) 108 | continue 109 | 110 | if 'from' in result and result['from'] == 'app': 111 | client_ip = result['ip'] if 'ip' in result else ip 112 | 113 | if client_ip not in broadcasted_apps: 114 | log.info( 'New Broadcast from App at %r (%r) - %r', client_ip, ip, result ) 115 | else: 116 | log.debug( 'Updated Broadcast from App at %r (%r) - %r', client_ip, ip, result ) 117 | 118 | broadcasted_apps[client_ip] = time.time() + (2 * BROADCASTTIME) 119 | 120 | if broadcast_query_timer < time.time(): 121 | broadcast_query_timer = time.time() + BROADCASTTIME 122 | our_broadcasts = send_broadcast() 123 | continue 124 | elif 'gwId' in result: 125 | # queried v3.5 device response, let it fall through 126 | pass 127 | else: 128 | log.warning( 'New Broadcast from App does not contain app data! src:%r - %r', ip, result ) 129 | continue 130 | 131 | elif sock is client: 132 | tgt_port = UDPPORT 133 | elif sock is clients: 134 | tgt_port = UDPPORTS 135 | else: 136 | tgt_port = '???' 137 | log.warning( 'Sock not known??' ) 138 | 139 | #log.debug("UDP Packet from %r port %r", ip, tgt_port) 140 | 141 | #if 'gwId' not in result: 142 | # print("* Payload missing required 'gwId' - from %r to port %r: %r (%r)\n" % (ip, tgt_port, result, data)) 143 | # log.debug("UDP Packet payload missing required 'gwId' - from %r port %r - %r", ip, tgt_port, data) 144 | # continue 145 | 146 | need_delete = [] 147 | for client_ip in broadcasted_apps: 148 | if broadcasted_apps[client_ip] < time.time(): 149 | need_delete.append( client_ip ) 150 | continue 151 | 152 | dst = (client_ip, tgt_port) 153 | #log.debug( 'Sending to: %r', dst ) 154 | send_sock.sendto( data, dst ) 155 | 156 | for client_ip in need_delete: 157 | log.info( 'Client App aged out: %r', client_ip ) 158 | del broadcasted_apps[client_ip] 159 | 160 | client.close() 161 | clients.close() 162 | clientapp.close() 163 | 164 | return 165 | 166 | def send_broadcast(): 167 | """ 168 | Send broadcasts to query for newer v3.5 devices 169 | """ 170 | our_broadcasts = send_discovery_request() 171 | if not our_broadcasts: 172 | our_broadcasts = {} 173 | return our_broadcasts 174 | 175 | if __name__ == '__main__': 176 | disc = 'Listens for broadcast packets from Tuya devices and sends them via unicast to App clients. Useful to make the app work on broadcast-blocking WiFi networks.' 177 | epi = None #'The "-s" option is designed to make the output display packets in the correct order when sorted, i.e. with `python3 pcap_parse.py ... | sort`' 178 | arg_parser = argparse.ArgumentParser( description=disc, epilog=epi ) 179 | arg_parser.add_argument( '-debug', '-d', help='Enable debug messages', action='store_true' ) 180 | 181 | if HAVE_ARGCOMPLETE: 182 | argcomplete.autocomplete( arg_parser ) 183 | 184 | args = arg_parser.parse_args() 185 | 186 | logging.basicConfig( level=logging.DEBUG + (0 if args.debug else 1) ) 187 | 188 | relay( args ) 189 | -------------------------------------------------------------------------------- /tinytuya/core/Device.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Module 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | import time 6 | 7 | from .XenonDevice import XenonDevice, merge_dps_results 8 | from . import command_types as CT 9 | 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | class Device(XenonDevice): 14 | #def __init__(self, *args, **kwargs): 15 | # super(Device, self).__init__(*args, **kwargs) 16 | 17 | def set_status(self, on, switch=1, nowait=False): 18 | """ 19 | Set status of the device to 'on' or 'off'. 20 | 21 | Args: 22 | on(bool): True for 'on', False for 'off'. 23 | switch(int): The switch to set 24 | nowait(bool): True to send without waiting for response. 25 | """ 26 | # open device, send request, then close connection 27 | if isinstance(switch, int): 28 | switch = str(switch) # index and payload is a string 29 | payload = self.generate_payload(CT.CONTROL, {switch: on}) 30 | 31 | data = self._send_receive(payload, getresponse=(not nowait)) 32 | log.debug("set_status received data=%r", data) 33 | 34 | return data 35 | 36 | def product(self): 37 | """ 38 | Request AP_CONFIG Product Info from device. [BETA] 39 | 40 | """ 41 | # open device, send request, then close connection 42 | payload = self.generate_payload(CT.AP_CONFIG) 43 | data = self._send_receive(payload, 0) 44 | log.debug("product received data=%r", data) 45 | return data 46 | 47 | def heartbeat(self, nowait=True): 48 | """ 49 | Send a keep-alive HEART_BEAT command to keep the TCP connection open. 50 | 51 | Devices only send an empty-payload response, so no need to wait for it. 52 | 53 | Args: 54 | nowait(bool): True to send without waiting for response. 55 | """ 56 | # open device, send request, then close connection 57 | payload = self.generate_payload(CT.HEART_BEAT) 58 | data = self._send_receive(payload, 0, getresponse=(not nowait)) 59 | log.debug("heartbeat received data=%r", data) 60 | return data 61 | 62 | def updatedps(self, index=None, nowait=False): 63 | """ 64 | Request device to update index. 65 | 66 | Args: 67 | index(array): list of dps to update (ex. [4, 5, 6, 18, 19, 20]) 68 | nowait(bool): True to send without waiting for response. 69 | """ 70 | if index is None: 71 | index = [1] 72 | 73 | log.debug("updatedps() entry (dev_type is %s)", self.dev_type) 74 | # open device, send request, then close connection 75 | payload = self.generate_payload(CT.UPDATEDPS, index) 76 | data = self._send_receive(payload, 0, getresponse=(not nowait)) 77 | log.debug("updatedps received data=%r", data) 78 | return data 79 | 80 | def set_value(self, index, value, nowait=False): 81 | """ 82 | Set int value of any index. 83 | 84 | Args: 85 | index(int): index to set 86 | value(int): new value for the index 87 | nowait(bool): True to send without waiting for response. 88 | """ 89 | # open device, send request, then close connection 90 | if isinstance(index, int): 91 | index = str(index) # index and payload is a string 92 | 93 | payload = self.generate_payload(CT.CONTROL, {index: value}) 94 | 95 | data = self._send_receive(payload, getresponse=(not nowait)) 96 | 97 | return data 98 | 99 | def set_multiple_values(self, data, nowait=False): 100 | """ 101 | Set multiple indexes at the same time 102 | 103 | Args: 104 | data(dict): array of index/value pairs to set 105 | nowait(bool): True to send without waiting for response. 106 | """ 107 | # if nowait is set we can't detect failure 108 | if nowait: 109 | if self.max_simultaneous_dps > 0 and len(data) > self.max_simultaneous_dps: 110 | # too many DPs, break it up into smaller chunks 111 | ret = None 112 | for k in data: 113 | ret = self.set_value(k, data[k], nowait=nowait) 114 | return ret 115 | else: 116 | # send them all. since nowait is set we can't detect failure 117 | out = {} 118 | for k in data: 119 | out[str(k)] = data[k] 120 | payload = self.generate_payload(CT.CONTROL, out) 121 | return self._send_receive(payload, getresponse=(not nowait)) 122 | 123 | if self.max_simultaneous_dps > 0 and len(data) > self.max_simultaneous_dps: 124 | # too many DPs, break it up into smaller chunks 125 | ret = {} 126 | for k in data: 127 | if (not nowait) and bool(ret): 128 | time.sleep(1) 129 | result = self.set_value(k, data[k], nowait=nowait) 130 | merge_dps_results(ret, result) 131 | return ret 132 | 133 | # send them all, but try to detect devices which cannot handle multiple 134 | out = {} 135 | for k in data: 136 | out[str(k)] = data[k] 137 | 138 | payload = self.generate_payload(CT.CONTROL, out) 139 | result = self._send_receive(payload, getresponse=(not nowait)) 140 | 141 | if result and 'Err' in result and len(out) > 1: 142 | # sending failed! device might only be able to handle 1 DP at a time 143 | first_dp = next(iter( out )) 144 | res = self.set_value(first_dp, out[first_dp], nowait=nowait) 145 | del out[first_dp] 146 | if res and 'Err' not in res: 147 | # single DP succeeded! set limit to 1 148 | self.max_simultaneous_dps = 1 149 | result = res 150 | for k in out: 151 | res = self.set_value(k, out[k], nowait=nowait) 152 | merge_dps_results(result, res) 153 | return result 154 | 155 | def turn_on(self, switch=1, nowait=False): 156 | """Turn the device on""" 157 | return self.set_status(True, switch, nowait) 158 | 159 | def turn_off(self, switch=1, nowait=False): 160 | """Turn the device off""" 161 | return self.set_status(False, switch, nowait) 162 | 163 | def set_timer(self, num_secs, dps_id=0, nowait=False): 164 | """ 165 | Set a timer. 166 | 167 | Args: 168 | num_secs(int): Number of seconds 169 | dps_id(int): DPS Index for Timer 170 | nowait(bool): True to send without waiting for response. 171 | """ 172 | 173 | # Query status, pick last device id as that is probably the timer 174 | if dps_id == 0: 175 | status = self.status() 176 | if "dps" in status: 177 | devices = status["dps"] 178 | devices_numbers = list(devices.keys()) 179 | devices_numbers.sort() 180 | dps_id = devices_numbers[-1] 181 | else: 182 | log.debug("set_timer received error=%r", status) 183 | return status 184 | 185 | payload = self.generate_payload(CT.CONTROL, {dps_id: num_secs}) 186 | 187 | data = self._send_receive(payload, getresponse=(not nowait)) 188 | log.debug("set_timer received data=%r", data) 189 | return data 190 | -------------------------------------------------------------------------------- /tinytuya/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # PYTHON_ARGCOMPLETE_OK 4 | # TinyTuya Module 5 | """ 6 | Python module to interface with Tuya WiFi smart devices 7 | 8 | Author: Jason A. Cox 9 | For more information see https://github.com/jasonacox/tinytuya 10 | 11 | Run TinyTuya Setup Wizard: 12 | python -m tinytuya wizard 13 | This network scan will run if calling this module via command line: 14 | python -m tinytuya 15 | 16 | """ 17 | 18 | # Modules 19 | import sys 20 | import argparse 21 | try: 22 | import argcomplete 23 | HAVE_ARGCOMPLETE = True 24 | except: 25 | HAVE_ARGCOMPLETE = False 26 | 27 | from . import wizard, scanner, version, SCANTIME, DEVICEFILE, SNAPSHOTFILE, CONFIGFILE, RAWFILE, set_debug 28 | 29 | prog = 'python3 -m tinytuya' if sys.argv[0][-11:] == '__main__.py' else None 30 | description = 'TinyTuya [%s]' % (version,) 31 | parser = argparse.ArgumentParser( prog=prog, description=description ) 32 | 33 | # Options for all functions. 34 | # Add both here and in subparsers (with alternate `dest=`) if you want to allow it to be positioned anywhere 35 | parser.add_argument( '-debug', '-d', help='Enable debug messages', action='store_true' ) 36 | parser.add_argument( '-v', '--version', help='Display version information', action='store_true' ) 37 | 38 | subparser = parser.add_subparsers( dest='command', title='commands (run -h to see usage information)' ) 39 | subparsers = {} 40 | cmd_list = { 41 | 'wizard': 'Launch Setup Wizard to get Device Local Keys', 42 | 'scan': 'Scan local network for Tuya devices', 43 | 'devices': 'Scan all devices listed in device-file', 44 | 'snapshot': 'Scan devices listed in snapshot-file', 45 | 'json': 'Scan devices listed in snapshot-file and display the result as JSON' 46 | } 47 | for sp in cmd_list: 48 | subparsers[sp] = subparser.add_parser(sp, help=cmd_list[sp]) 49 | subparsers[sp].add_argument( '-debug', '-d', help='Enable debug messages', action='store_true', dest='debug2' ) 50 | 51 | if sp != 'json': 52 | if sp != 'snapshot': 53 | subparsers[sp].add_argument( 'max_time', help='Maximum time to find Tuya devices [Default: %s]' % SCANTIME, nargs='?', type=int ) 54 | subparsers[sp].add_argument( '-force', '-f', metavar='0.0.0.0/24', help='Force network scan of device IP addresses. Auto-detects net/mask if none provided', action='append', nargs='*' ) 55 | subparsers[sp].add_argument( '-no-broadcasts', help='Ignore broadcast packets when force scanning', action='store_true' ) 56 | 57 | subparsers[sp].add_argument( '-nocolor', help='Disable color text output', action='store_true' ) 58 | subparsers[sp].add_argument( '-yes', '-y', help='Answer "yes" to all questions', action='store_true' ) 59 | if sp != 'scan': 60 | subparsers[sp].add_argument( '-no-poll', '-no', help='Answer "no" to "Poll?" (overrides -yes)', action='store_true' ) 61 | 62 | if sp == 'wizard': 63 | help = 'JSON file to load/save devices from/to [Default: %s]' % DEVICEFILE 64 | subparsers[sp].add_argument( '-device-file', help=help, default=DEVICEFILE, metavar='FILE' ) 65 | subparsers[sp].add_argument( '-raw-response-file', help='JSON file to save the raw server response to [Default: %s]' % RAWFILE, default=RAWFILE, metavar='FILE' ) 66 | else: 67 | help = 'JSON file to load devices from [Default: %s]' % DEVICEFILE 68 | subparsers[sp].add_argument( '-device-file', help=help, default=DEVICEFILE, metavar='FILE' ) 69 | 70 | if sp == 'json': 71 | # Throw error if file does not exist 72 | subparsers[sp].add_argument( '-snapshot-file', help='JSON file to load snapshot from [Default: %s]' % SNAPSHOTFILE, default=SNAPSHOTFILE, metavar='FILE', type=argparse.FileType('r') ) 73 | else: 74 | # May not exist yet, will be created 75 | subparsers[sp].add_argument( '-snapshot-file', help='JSON file to load/save snapshot from/to [Default: %s]' % SNAPSHOTFILE, default=SNAPSHOTFILE, metavar='FILE' ) 76 | 77 | # Looks neater in a group 78 | cred_group = subparsers['wizard'].add_argument_group( 'Cloud API Credentials', 'If no option is set then the Wizard will prompt') 79 | cred_group.add_argument( '-credentials-file', help='JSON file to load/save Cloud credentials from/to [Default: %s]' % CONFIGFILE, metavar='FILE' ) 80 | cred_group.add_argument( '-key', help='Cloud API Key to use' ) 81 | cred_group.add_argument( '-secret', help='Cloud API Secret to use' ) 82 | cred_group.add_argument( '-region', help='Cloud API Region to use', choices=('cn', 'eu', 'eu-w', 'in', 'us', 'us-e') ) 83 | cred_group.add_argument( '-device', help='One or more Device ID(s) to use', action='append', nargs='+' ) 84 | 85 | subparsers['wizard'].add_argument( '-dry-run', help='Do not actually connect to the Cloud', action='store_true' ) 86 | 87 | if HAVE_ARGCOMPLETE: 88 | argcomplete.autocomplete( parser ) 89 | 90 | args = parser.parse_args() 91 | 92 | if args.version: 93 | print('TinyTuya version:', version) 94 | sys.exit(0) 95 | 96 | if args.debug: 97 | print('Parsed args:', args) 98 | set_debug(True) 99 | 100 | if args.command: 101 | if args.debug2 and not args.debug: 102 | print('Parsed args:', args) 103 | set_debug(True) 104 | 105 | if args.command == 'wizard' and args.raw_response_file: 106 | wizard.RAWFILE = args.raw_response_file 107 | 108 | if args.device_file: 109 | if type(args.device_file) == str: 110 | scanner.DEVICEFILE = args.device_file 111 | wizard.DEVICEFILE = args.device_file 112 | else: 113 | fname = args.device_file.name 114 | args.device_file.close() 115 | args.device_file = fname 116 | scanner.DEVICEFILE = fname 117 | wizard.DEVICEFILE = fname 118 | 119 | if args.snapshot_file: 120 | if args.command == 'json': 121 | scanner.SNAPSHOTFILE = args.snapshot_file.name 122 | args.snapshot_file.close() 123 | args.snapshot_file = scanner.SNAPSHOTFILE 124 | else: 125 | scanner.SNAPSHOTFILE = args.snapshot_file 126 | wizard.SNAPSHOTFILE = args.snapshot_file 127 | 128 | if args.command == 'scan': 129 | scanner.scan( scantime=args.max_time, color=(not args.nocolor), forcescan=args.force, discover=(not args.no_broadcasts), assume_yes=args.yes ) 130 | elif args.command == 'snapshot': 131 | scanner.snapshot( color=(not args.nocolor), assume_yes=args.yes, skip_poll=args.no_poll ) 132 | elif args.command == 'devices': 133 | scanner.alldevices( scantime=args.max_time, color=(not args.nocolor), forcescan=args.force, discover=(not args.no_broadcasts), assume_yes=args.yes, skip_poll=args.no_poll ) 134 | elif args.command == 'json': 135 | scanner.snapshotjson() 136 | elif args.command == 'wizard': 137 | if args.credentials_file: 138 | wizard.CONFIGFILE = args.credentials_file 139 | creds = { 'file': args.credentials_file, 'apiKey': args.key, 'apiSecret': args.secret, 'apiRegion': args.region, 'apiDeviceID': None } 140 | if args.device: 141 | creds['apiDeviceID'] = ','.join(sum(args.device, [])) 142 | wizard.wizard( color=(not args.nocolor), retries=args.max_time, forcescan=args.force, nocloud=args.dry_run, assume_yes=args.yes, discover=(not args.no_broadcasts), skip_poll=args.no_poll, credentials=creds ) 143 | else: 144 | # No command selected - show help 145 | parser.print_help() 146 | 147 | # Entry_points/console_scripts endpoints require a function to be called 148 | def dummy(): 149 | pass 150 | 151 | # End 152 | -------------------------------------------------------------------------------- /examples/galaxy_projector.py: -------------------------------------------------------------------------------- 1 | import colorsys 2 | from dataclasses import dataclass 3 | from typing import Tuple, List, Literal 4 | 5 | import tinytuya 6 | 7 | HSV = Tuple[float, float, float] 8 | 9 | SCENE_CHANGE_MODES = {'static': '00', 'flash': '01', 'breath': '02'} 10 | SCENE_NAMES = {'sleep': '04', 'romantic': '05', 'party': '06', 'relaxing': '07'} 11 | SCENE_SPEED_MIN, SCENE_SPEED_MAX = 0x2828, 0x6464 12 | 13 | 14 | class GalaxyProjector: 15 | """ 16 | Works with the Galaxy Projector from galaxylamps.co: 17 | https://eu.galaxylamps.co/collections/all/products/galaxy-projector 18 | """ 19 | 20 | def __init__(self, tuya_device_id: str, device_ip_addr: str, tuya_secret_key: str): 21 | self.device = tinytuya.BulbDevice(tuya_device_id, device_ip_addr, tuya_secret_key) 22 | self.device.set_version(3.3) 23 | self.state = GalaxyProjectorState() 24 | self.update_state() 25 | 26 | def set_device_power(self, *, on: bool): 27 | self.state.update(self.device.set_status(switch=20, on=on)) 28 | 29 | def set_stars_power(self, *, on: bool): 30 | self.state.update(self.device.set_status(switch=102, on=on)) 31 | 32 | def set_nebula_power(self, *, on: bool): 33 | self.state.update(self.device.set_status(switch=103, on=on)) 34 | 35 | def set_rotation_speed(self, *, percent: float): 36 | value = int(10 + (1000 - 10) * min(max(percent, 0), 100) / 100) 37 | self.state.update(self.device.set_value(101, value)) 38 | 39 | def set_stars_brightness(self, *, percent: float): 40 | self.state.update(self.device.set_white_percentage(percent)) 41 | 42 | def set_nebula_color(self, *, hsv: HSV): 43 | """scene mode needs to be off to set static nebula color""" 44 | self.state.update(self.device.set_hsv(*hsv)) 45 | 46 | def set_scene_mode(self, *, on: bool): 47 | self.state.update(self.device.set_mode('scene' if on else 'colour')) 48 | # differentiation between 'white' and 'colour' not relevant for this device 49 | 50 | def set_scene(self, parts: List["SceneTransition"]): 51 | """scene mode needs to be on""" 52 | output = SCENE_NAMES['party'] # scene name doesn't seem to matter 53 | for part in parts: 54 | output += hex(int( 55 | part.change_speed_percent / 100 * (SCENE_SPEED_MAX - SCENE_SPEED_MIN) + SCENE_SPEED_MIN))[2:] 56 | output += str(SCENE_CHANGE_MODES[part.change_mode]) 57 | output += hsv2tuyahex(*part.nebula_hsv) + '00000000' 58 | self.device.set_value(25, output) 59 | self.update_state() # return value of previous command is truncated and not usable for state update 60 | 61 | def update_state(self): 62 | self.state.update(self.device.status()) 63 | 64 | 65 | @dataclass 66 | class SceneTransition: 67 | change_speed_percent: int 68 | change_mode: Literal['static', 'flash', 'breath'] 69 | nebula_hsv: HSV 70 | 71 | 72 | class GalaxyProjectorState: 73 | """ 74 | Data Points (dps): 75 | 20 device on/off 76 | 21 work_mode: white(stars), colour (nebula), scene, music 77 | 22 stars brightness 10-1000 78 | 24 nebula hsv 79 | 25 scene value 80 | 26 shutdown timer 81 | 101 stars speed 10-1000 82 | 102 stars on/off 83 | 103 nebula on/off 84 | """ 85 | 86 | def __init__(self, dps=None): 87 | self.dps = dps or {} 88 | 89 | def update(self, payload): 90 | payload = payload or {'dps': {}} 91 | if 'Err' in payload: 92 | raise Exception(payload) 93 | self.dps.update(payload['dps']) 94 | 95 | @property 96 | def device_on(self) -> bool: 97 | return self.dps['20'] 98 | 99 | @property 100 | def stars_on(self) -> bool: 101 | return self.dps['102'] 102 | 103 | @property 104 | def nebula_on(self) -> bool: 105 | return self.dps['103'] 106 | 107 | @property 108 | def scene_mode(self) -> bool: 109 | return self.dps['21'] == 'scene' 110 | 111 | @property 112 | def scene(self) -> List["SceneTransition"]: 113 | output = [] 114 | hex_scene = self.dps['25'] 115 | hex_scene_name = hex_scene[0:2] # scene name doesn't seem to matter 116 | i = 2 117 | while i < len(hex_scene): 118 | hex_scene_speed = int(hex_scene[i:i + 4], 16) 119 | hex_scene_change = hex_scene[i + 4:i + 6] 120 | hex_scene_color = hex_scene[i + 6:i + 18] 121 | for k, v in SCENE_CHANGE_MODES.items(): 122 | if v == hex_scene_change: 123 | scene_change = k 124 | break 125 | else: 126 | raise Exception(f'unknown scene change value: {hex_scene_change}') 127 | 128 | output.append(SceneTransition( 129 | change_speed_percent=round( 130 | (hex_scene_speed - SCENE_SPEED_MIN) * 100 / (SCENE_SPEED_MAX - SCENE_SPEED_MIN)), 131 | change_mode=scene_change, 132 | nebula_hsv=tuyahex2hsv(hex_scene_color) 133 | )) 134 | i += 26 135 | return output 136 | 137 | @property 138 | def stars_brightness_percent(self): 139 | return int((self.dps['22'] - 10) * 100 / (1000 - 10)) 140 | 141 | @property 142 | def rotation_speed_percent(self): 143 | return int((self.dps['101'] - 10) * 100 / (1000 - 10)) 144 | 145 | @property 146 | def nebula_hsv(self) -> HSV: 147 | return tuyahex2hsv(self.dps['24']) 148 | 149 | def __repr__(self): 150 | return f'GalaxyProjectorState<{self.parsed_value}>' 151 | 152 | @property 153 | def parsed_value(self): 154 | return {k: getattr(self, k) for k in ( 155 | 'device_on', 'stars_on', 'nebula_on', 'scene_mode', 'scene', 'stars_brightness_percent', 156 | 'rotation_speed_percent', 'nebula_hsv')} 157 | 158 | 159 | def tuyahex2hsv(val: str): 160 | return tinytuya.BulbDevice._hexvalue_to_hsv(val, bulb="B") 161 | 162 | 163 | def hsv2tuyahex(h: float, s: float, v: float): 164 | (r, g, b) = colorsys.hsv_to_rgb(h, s, v) 165 | hexvalue = tinytuya.BulbDevice._rgb_to_hexvalue( 166 | r * 255.0, g * 255.0, b * 255.0, bulb='B' 167 | ) 168 | return hexvalue 169 | 170 | 171 | if __name__ == '__main__': 172 | proj = GalaxyProjector(tuya_device_id=input('Tuya Device ID: '), device_ip_addr=input('Device IP Addr: '), 173 | tuya_secret_key=input('Tuya Device Secret/Local Key: ')) 174 | print() 175 | print('Current state:', proj.state.parsed_value) 176 | print() 177 | 178 | print('Press enter to continue') 179 | print() 180 | 181 | input('Turn stars off') 182 | proj.set_device_power(on=True) 183 | proj.set_stars_power(on=False) 184 | 185 | input('Turn stars on') 186 | proj.set_stars_power(on=True) 187 | 188 | input('Set stars brightness to 100%') 189 | proj.set_stars_brightness(percent=100) 190 | 191 | input('Set stars brightness to 0% (minimal)') 192 | proj.set_stars_brightness(percent=0) 193 | 194 | input('Set rotation speed to 100%') 195 | proj.set_rotation_speed(percent=100) 196 | 197 | input('Set rotation speed to 0%') 198 | proj.set_rotation_speed(percent=0) 199 | 200 | input('Set nebula color to red') 201 | proj.set_nebula_color(hsv=(0, 1, 1)) 202 | 203 | input('Reduce nebula brightness') 204 | proj.set_nebula_color(hsv=(0, 1, .3)) 205 | 206 | input('Show Scene') 207 | proj.set_scene([SceneTransition(change_speed_percent=80, change_mode='breath', nebula_hsv=(.5, 1, 1)), 208 | SceneTransition(change_speed_percent=80, change_mode='breath', nebula_hsv=(0, 0, 1))]) 209 | proj.set_scene_mode(on=True) 210 | 211 | input('Turn device off') 212 | proj.set_device_power(on=False) 213 | -------------------------------------------------------------------------------- /server/mqtt/mqtt_gateway.py: -------------------------------------------------------------------------------- 1 | # TinyTuya MQTT Gateway 2 | # -*- coding: utf-8 -*- 3 | """ 4 | TinyTuya MQTT Gateway for API Server for Tuya based WiFi smart devices 5 | 6 | Author: @mschlenstedt 7 | Date: June 11, 2023 8 | For more information see https://github.com/jasonacox/tinytuya 9 | 10 | Description 11 | 12 | """ 13 | 14 | # Modules 15 | import paho.mqtt.client as mqtt 16 | import time 17 | import logging 18 | import json 19 | try: 20 | import requests 21 | except ImportError as impErr: 22 | print("WARN: Unable to import requests library, Cloud functions will not work.") 23 | print("WARN: Check dependencies. See https://github.com/jasonacox/tinytuya/issues/377") 24 | print("WARN: Error: {}.".format(impErr.args[0])) 25 | import sys 26 | import os 27 | import copy 28 | import concurrent.futures 29 | import threading 30 | from queue import Queue 31 | 32 | BUILD = "t2" 33 | 34 | # Defaults 35 | DEBUGMODE = False 36 | POLLINGTIME = 5 37 | TOPIC = "tinytuya" 38 | BROKER = "localhost" 39 | BROKERPORT = "1883" 40 | APIPORT = 8888 41 | 42 | # Check for Environmental Overrides 43 | debugmode = os.getenv("DEBUG", "no") 44 | if debugmode.lower() == "yes": 45 | DEBUGMODE = True 46 | 47 | # Logging 48 | log = logging.getLogger(__name__) 49 | if len(sys.argv) > 1 and sys.argv[1].startswith("-d"): 50 | DEBUGMODE = True 51 | if DEBUGMODE: 52 | logging.basicConfig( 53 | format="\x1b[31;1m%(levelname)s [%(asctime)s]:%(message)s\x1b[0m", level=logging.DEBUG, 54 | datefmt='%d/%b/%y %H:%M:%S' 55 | ) 56 | log.setLevel(logging.DEBUG) 57 | log.debug("TinyTuya (MQTT Gateway) [%s]", BUILD) 58 | print("\nTinyTuya (MQTT Gateway) [%s]\n" % BUILD) 59 | 60 | # Global Variables 61 | running = True 62 | q=Queue() 63 | mqttconfig = {} 64 | last = 0 65 | devices = {} 66 | 67 | # Helpful Functions 68 | 69 | def on_connect(client, userdata, flags, rc): 70 | if rc==0: 71 | client.connected_flag=True #set flag 72 | log.debug("Connected OK") 73 | try: 74 | client.publish(mqttconfig['topic'] + "/running", str("1"), retain=1) 75 | except: 76 | log.debug("Cannot set topic %s", mqttconfig['topic'] + "/running") 77 | else: 78 | log.debug("Bad connection, Returned code %s", rc) 79 | 80 | def on_message(client, userdata, message): 81 | q.put(message) 82 | 83 | def readconfig(): 84 | config = {} 85 | try: 86 | with open('mqtt.json') as f: 87 | config = json.load(f) 88 | except: 89 | print("Cannot read mqtt config - using defaults.") 90 | log.debug("Cannot read mqtt config - using defaults.") 91 | if 'topic' not in config: 92 | config['topic'] = TOPIC 93 | if 'broker' not in config: 94 | config['broker'] = BROKER 95 | if 'port' not in config: 96 | config['port'] = BROKERPORT 97 | if 'pollingtime' not in config: 98 | config['pollingtime'] = POLLINGTIME 99 | log.debug("Config %s", config) 100 | return (config) 101 | 102 | def getdevices(): 103 | data = {} 104 | try: 105 | url = "http://localhost:" + str(APIPORT) + "/devices" 106 | with requests.get(url) as response: 107 | response.raise_for_status() 108 | data = response.json() 109 | except: 110 | log.debug("Cannot get devices list from server") 111 | data = {} 112 | return(data) 113 | 114 | def get_session(): 115 | if not hasattr(thread_local, "session"): 116 | thread_local.session = requests.Session() 117 | return thread_local.session 118 | 119 | def get_status(id): 120 | session = get_session() 121 | try: 122 | url = "http://localhost:" + str(APIPORT) + "/status/" + id 123 | with session.get(url) as response: 124 | response.raise_for_status() 125 | data = response.json() 126 | status_raw = data['dps'] 127 | status = copy.deepcopy(status_raw) 128 | if 'dps_mapping' in data: 129 | mapping = data['dps_mapping'] 130 | keysList = list(status_raw.keys()) 131 | for i in keysList: 132 | newname = "" 133 | for j in mapping: 134 | if str(j) == str(i): 135 | newname = mapping[j]['code'] 136 | break 137 | if newname != "": 138 | status[newname] = status.pop(i) 139 | client.publish(mqttconfig['topic'] + "/" + id + "/status_raw", json.dumps(status_raw), retain=1) 140 | client.publish(mqttconfig['topic'] + "/" + id + "/status", json.dumps(status), retain=1) 141 | client.publish(mqttconfig['topic'] + "/" + id + "/last", str(int(time.time())), retain=1) 142 | for d in devices: 143 | if str(devices[d]['id']) == str(id): 144 | client.publish(mqttconfig['topic'] + "/" + id + "/device", json.dumps(devices[d]), retain=1) 145 | break 146 | except: 147 | log.debug("Cannot read status for device %s", str(id)) 148 | 149 | def get_status_all(sdevices): 150 | with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor: 151 | executor.map(get_status, sdevices) 152 | 153 | def set_dps(url): 154 | try: 155 | url = "http://localhost:" + str(APIPORT) + "/set/" + url 156 | with requests.get(url) as response: 157 | response.raise_for_status() 158 | data = response.json() 159 | except: 160 | log.debug("Cannot read set dps %s", str(url)) 161 | 162 | # Main 163 | 164 | if __name__ == "__main__": 165 | 166 | mqttconfig = readconfig() 167 | 168 | # Conncect to broker 169 | client = mqtt.Client() 170 | client.will_set(mqttconfig['topic'] + "/running", str("0"), 0, True) 171 | client.connected_flag=False 172 | client.on_connect = on_connect 173 | if 'username' in mqttconfig and 'password' in mqttconfig: 174 | if mqttconfig['username'] != "" and mqttconfig['password'] != "": 175 | client.username_pw_set(username = mqttconfig['username'],password = mqttconfig['password']) 176 | log.debug("Connecting to Broker %s on port %s." % (mqttconfig['broker'], str(mqttconfig['port']))) 177 | client.connect(mqttconfig['broker'], port = int(mqttconfig['port'])) 178 | 179 | # Subscribe to the set topic 180 | stopic = mqttconfig['topic'] + "/set/#" 181 | client.subscribe(stopic, qos=0) 182 | client.on_message = on_message 183 | client.loop_start() 184 | 185 | # Wait for MQTT connection 186 | counter=0 187 | while not client.connected_flag: #wait in loop 188 | time.sleep(1) 189 | counter+=1 190 | if counter > 60: 191 | print("Cannot connect to Broker %s on port %s." % (mqttconfig['broker'], str(mqttconfig['port']))) 192 | log.debug("Cannot connect to Broker %s on port %s." % (mqttconfig['broker'], str(mqttconfig['port']))) 193 | sys.exit(2) 194 | 195 | # Loop 196 | thread_local = threading.local() 197 | last = 0 198 | while(True): 199 | now = time.time() 200 | # Check for any subscribed messages in the queue 201 | while not q.empty(): 202 | message = q.get() 203 | if message is None or str(message.payload.decode("utf-8")) == "": 204 | continue 205 | log.debug("Received: %s at topic %s" % ( str(message.payload.decode("utf-8")), str(message.topic) )) 206 | id, dpsKey = str(message.topic).replace(mqttconfig['topic'] + "/set/", "").split("/", 1) 207 | set_dps( str(message.topic).replace(mqttconfig['topic'] + "/set/", "") + "/" + str(message.payload.decode("utf-8")) ) 208 | time.sleep(0.5) 209 | get_status(id) 210 | # Get status 211 | if last + int(mqttconfig['pollingtime']) < now: 212 | last = time.time() 213 | devices = getdevices() 214 | get_status_all(devices) 215 | # Slow down 216 | time.sleep(0.1) 217 | -------------------------------------------------------------------------------- /tinytuya/core/message_helper.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Module 2 | # -*- coding: utf-8 -*- 3 | 4 | import binascii 5 | from collections import namedtuple 6 | import hmac 7 | import logging 8 | import struct 9 | from hashlib import sha256 10 | 11 | from .crypto_helper import AESCipher 12 | from .exceptions import DecodeError 13 | from . import header as H 14 | 15 | log = logging.getLogger(__name__) 16 | 17 | 18 | # Tuya Packet Format 19 | TuyaHeader = namedtuple('TuyaHeader', 'prefix seqno cmd length total_length') 20 | MessagePayload = namedtuple("MessagePayload", "cmd payload") 21 | try: 22 | TuyaMessage = namedtuple("TuyaMessage", "seqno cmd retcode payload crc crc_good prefix iv", defaults=(True,0x55AA,None)) 23 | except: 24 | TuyaMessage = namedtuple("TuyaMessage", "seqno cmd retcode payload crc crc_good prefix iv") 25 | 26 | 27 | def pack_message(msg, hmac_key=None): 28 | """Pack a TuyaMessage into bytes.""" 29 | if msg.prefix == H.PREFIX_55AA_VALUE: 30 | header_fmt = H.MESSAGE_HEADER_FMT_55AA 31 | end_fmt = H.MESSAGE_END_FMT_HMAC if hmac_key else H.MESSAGE_END_FMT_55AA 32 | msg_len = len(msg.payload) + struct.calcsize(end_fmt) 33 | header_data = ( msg.prefix, msg.seqno, msg.cmd, msg_len ) 34 | elif msg.prefix == H.PREFIX_6699_VALUE: 35 | if not hmac_key: 36 | raise TypeError( 'key must be provided to pack 6699-format messages' ) 37 | header_fmt = H.MESSAGE_HEADER_FMT_6699 38 | end_fmt = H.MESSAGE_END_FMT_6699 39 | msg_len = len(msg.payload) + (struct.calcsize(end_fmt) - 4) + 12 40 | if type(msg.retcode) == int: 41 | msg_len += struct.calcsize(H.MESSAGE_RETCODE_FMT) 42 | header_data = ( msg.prefix, 0, msg.seqno, msg.cmd, msg_len ) 43 | else: 44 | raise ValueError( 'pack_message() cannot handle message format %08X' % msg.prefix ) 45 | 46 | # Create full message excluding CRC and suffix 47 | data = struct.pack( header_fmt, *header_data ) 48 | 49 | if msg.prefix == H.PREFIX_6699_VALUE: 50 | cipher = AESCipher( hmac_key ) 51 | if type(msg.retcode) == int: 52 | raw = struct.pack( H.MESSAGE_RETCODE_FMT, msg.retcode ) + msg.payload 53 | else: 54 | raw = msg.payload 55 | data2 = cipher.encrypt( raw, use_base64=False, pad=False, iv=True if not msg.iv else msg.iv, header=data[4:]) 56 | data += data2 + H.SUFFIX_6699_BIN 57 | else: 58 | data += msg.payload 59 | if hmac_key: 60 | crc = hmac.new(hmac_key, data, sha256).digest() 61 | else: 62 | crc = binascii.crc32(data) & 0xFFFFFFFF 63 | # Calculate CRC, add it together with suffix 64 | data += struct.pack( end_fmt, crc, H.SUFFIX_VALUE ) 65 | 66 | return data 67 | 68 | 69 | def parse_header(data): 70 | if( data[:4] == H.PREFIX_6699_BIN ): 71 | fmt = H.MESSAGE_HEADER_FMT_6699 72 | else: 73 | fmt = H.MESSAGE_HEADER_FMT_55AA 74 | 75 | header_len = struct.calcsize(fmt) 76 | 77 | if len(data) < header_len: 78 | raise DecodeError('Not enough data to unpack header') 79 | 80 | unpacked = struct.unpack( fmt, data[:header_len] ) 81 | prefix = unpacked[0] 82 | 83 | if prefix == H.PREFIX_55AA_VALUE: 84 | prefix, seqno, cmd, payload_len = unpacked 85 | total_length = payload_len + header_len 86 | elif prefix == H.PREFIX_6699_VALUE: 87 | prefix, unknown, seqno, cmd, payload_len = unpacked 88 | #seqno |= unknown << 32 89 | total_length = payload_len + header_len + len(H.SUFFIX_6699_BIN) 90 | else: 91 | #log.debug('Header prefix wrong! %08X != %08X', prefix, PREFIX_VALUE) 92 | raise DecodeError('Header prefix wrong! %08X is not %08X or %08X' % (prefix, H.PREFIX_55AA_VALUE, H.PREFIX_6699_VALUE)) 93 | 94 | # sanity check. currently the max payload length is somewhere around 300 bytes 95 | if payload_len > 1000: 96 | raise DecodeError('Header claims the packet size is over 1000 bytes! It is most likely corrupt. Claimed size: %d bytes. fmt:%s unpacked:%r' % (payload_len,fmt,unpacked)) 97 | 98 | return TuyaHeader(prefix, seqno, cmd, payload_len, total_length) 99 | 100 | 101 | def unpack_message(data, hmac_key=None, header=None, no_retcode=False): 102 | """Unpack bytes into a TuyaMessage.""" 103 | if header is None: 104 | header = parse_header(data) 105 | 106 | if header.prefix == H.PREFIX_55AA_VALUE: 107 | # 4-word header plus return code 108 | header_len = struct.calcsize(H.MESSAGE_HEADER_FMT_55AA) 109 | end_fmt = H.MESSAGE_END_FMT_HMAC if hmac_key else H.MESSAGE_END_FMT_55AA 110 | retcode_len = 0 if no_retcode else struct.calcsize(H.MESSAGE_RETCODE_FMT) 111 | msg_len = header_len + header.length 112 | elif header.prefix == H.PREFIX_6699_VALUE: 113 | if not hmac_key: 114 | raise TypeError( 'key must be provided to unpack 6699-format messages' ) 115 | header_len = struct.calcsize(H.MESSAGE_HEADER_FMT_6699) 116 | end_fmt = H.MESSAGE_END_FMT_6699 117 | retcode_len = 0 118 | msg_len = header_len + header.length + 4 119 | else: 120 | raise ValueError( 'unpack_message() cannot handle message format %08X' % header.prefix ) 121 | 122 | if len(data) < msg_len: 123 | log.debug('unpack_message(): not enough data to unpack payload! need %d but only have %d', header_len+header.length, len(data)) 124 | raise DecodeError('Not enough data to unpack payload') 125 | 126 | end_len = struct.calcsize(end_fmt) 127 | # the retcode is technically part of the payload, but strip it as we do not want it here 128 | retcode = 0 if not retcode_len else struct.unpack(H.MESSAGE_RETCODE_FMT, data[header_len:header_len+retcode_len])[0] 129 | payload = data[header_len+retcode_len:msg_len] 130 | crc, suffix = struct.unpack(end_fmt, payload[-end_len:]) 131 | crc_good = False 132 | payload = payload[:-end_len] 133 | 134 | if header.prefix == H.PREFIX_55AA_VALUE: 135 | if hmac_key: 136 | have_crc = hmac.new(hmac_key, data[:(header_len+header.length)-end_len], sha256).digest() 137 | else: 138 | have_crc = binascii.crc32(data[:(header_len+header.length)-end_len]) & 0xFFFFFFFF 139 | 140 | if suffix != H.SUFFIX_VALUE: 141 | log.debug('Suffix prefix wrong! %08X != %08X', suffix, H.SUFFIX_VALUE) 142 | 143 | if crc != have_crc: 144 | if hmac_key: 145 | log.debug('HMAC checksum wrong! %r != %r', binascii.hexlify(have_crc), binascii.hexlify(crc)) 146 | else: 147 | log.debug('CRC wrong! %08X != %08X', have_crc, crc) 148 | crc_good = crc == have_crc 149 | iv = None 150 | elif header.prefix == H.PREFIX_6699_VALUE: 151 | iv = payload[:12] 152 | payload = payload[12:] 153 | try: 154 | cipher = AESCipher( hmac_key ) 155 | payload = cipher.decrypt( payload, use_base64=False, decode_text=False, verify_padding=False, iv=iv, header=data[4:header_len], tag=crc) 156 | crc_good = True 157 | except: 158 | crc_good = False 159 | 160 | retcode_len = struct.calcsize(H.MESSAGE_RETCODE_FMT) 161 | if no_retcode is False: 162 | pass 163 | elif no_retcode is None and payload[0:1] != b'{' and payload[retcode_len:retcode_len+1] == b'{': 164 | retcode_len = struct.calcsize(H.MESSAGE_RETCODE_FMT) 165 | else: 166 | retcode_len = 0 167 | if retcode_len: 168 | retcode = struct.unpack(H.MESSAGE_RETCODE_FMT, payload[:retcode_len])[0] 169 | payload = payload[retcode_len:] 170 | 171 | return TuyaMessage(header.seqno, header.cmd, retcode, payload, crc, crc_good, header.prefix, iv) 172 | 173 | 174 | def has_suffix(payload): 175 | """Check to see if payload has valid Tuya suffix""" 176 | if len(payload) < 4: 177 | return False 178 | log.debug("buffer %r = %r", payload[-4:], H.SUFFIX_BIN) 179 | return payload[-4:] == H.SUFFIX_BIN 180 | -------------------------------------------------------------------------------- /examples/multi-select.py: -------------------------------------------------------------------------------- 1 | # TinyTuya Example 2 | # -*- coding: utf-8 -*- 3 | """ 4 | TinyTuya - Multi-select Example 5 | 6 | This demonstrates how to use pythons socket select() to listen for status updates 7 | from multiple Tuya devices. 8 | 9 | Setup: 10 | Set the config for each device and the script will open a socket connection for 11 | each device to listen for status updates. 12 | 13 | Author: Jason A. Cox 14 | For more information see https://github.com/jasonacox/tinytuya 15 | 16 | """ 17 | import select 18 | import time 19 | import tinytuya 20 | 21 | # Define the devices to control 22 | config = { 23 | "TuyaDevices": [ 24 | { 25 | "Address": "192.168.1.10", 26 | "Device ID": "00112233445566778899", 27 | "Local Key": "1234567890123abc", 28 | "Version": "3.3", 29 | }, 30 | { 31 | "Address": "192.168.1.11", 32 | "Device ID": "10112233445566778899", 33 | "Local Key": "1234567890123abc", 34 | "Version": "3.3", 35 | }, 36 | { 37 | "Address": "192.168.1.12", 38 | "Device ID": "20112233445566778899", 39 | "Local Key": "1234567890123abc", 40 | "Version": "3.3", 41 | }, 42 | { 43 | "Address": "192.168.1.13", 44 | "Device ID": "30112233445566778899", 45 | "Local Key": "1234567890123abc", 46 | "Version": "3.3", 47 | } 48 | ] 49 | } 50 | 51 | # Settings 52 | TTL_HEARTBEAT = 12 # Time in seconds between heartbeats 53 | 54 | def create_device(i): 55 | print(f"Connecting to {i['Device ID']} at {i['Address']} with key {i['Local Key']}") 56 | device = tinytuya.Device(i["Device ID"], i["Address"], i["Local Key"], version=i["Version"]) 57 | return device 58 | 59 | def reconnect_device(device_info, index, statuses, cool_down_time=5): 60 | """ 61 | Attempts to reconnect the device after a cool-down period and update the statuses. 62 | """ 63 | time.sleep(cool_down_time) # Cool-down before reconnection 64 | 65 | try: 66 | print(f"Reconnecting to {device_info['Device ID']}...") 67 | 68 | device = create_device(device_info) 69 | device.set_socketPersistent(True) 70 | initial_status = device.status() 71 | 72 | # Check if we successfully retrieved a valid status 73 | if "dps" in initial_status: 74 | print(f"Reconnected and got status from {device.id}: {initial_status}") 75 | statuses[index] = {"id": device.id, "status": initial_status["dps"]} 76 | else: 77 | raise Exception(f"Failed to get valid initial status after reconnect for {device.id}: {initial_status}") 78 | 79 | return device 80 | except Exception as e: 81 | print(f"Failed to reconnect to {device_info['Device ID']}: {e}") 82 | statuses[index] = {"id": device_info["Device ID"], "status": "Disconnected"} 83 | return None 84 | 85 | def send_heartbeat(device): 86 | """ 87 | Sends a heartbeat packet to keep the device connected. 88 | """ 89 | try: 90 | # Send a heartbeat packet 91 | device.heartbeat(nowait=True) 92 | print(f"Sent heartbeat to {device.id}") 93 | except Exception as e: 94 | print(f"Failed to send heartbeat to {device.id}: {e}") 95 | 96 | def getDeviceStatuses(devices, config): 97 | statuses = [None] * len(devices) # Initialize statuses list to hold results for each device 98 | 99 | # Enable persistent socket connection for each device 100 | for index, device in enumerate(devices): 101 | try: 102 | device.set_socketPersistent(True) 103 | initial_status = device.status() 104 | if "dps" in initial_status: 105 | print(f"INITIAL status from {device.id}: {initial_status}") 106 | statuses[index] = {"id": device.id, "status": initial_status["dps"]} 107 | else: 108 | print(f"Failed to get initial status from {device.id}") 109 | statuses[index] = {"id": device.id, "status": {}} 110 | except Exception as e: 111 | print(f"Error getting initial status from {device.id}: {e}") 112 | statuses[index] = {"id": device.id, "status": {}} 113 | 114 | # Create a list of sockets to monitor 115 | sockets = [device.socket for device in devices] 116 | 117 | last_heartbeat_time = time.time() # Track the last time a heartbeat was sent 118 | 119 | # Infinite loop to listen for status updates using select 120 | while True: 121 | # Send a heartbeat every 5 seconds to all devices 122 | if time.time() - last_heartbeat_time >= TTL_HEARTBEAT: 123 | for device in devices: 124 | send_heartbeat(device) 125 | last_heartbeat_time = time.time() # Reset heartbeat timer 126 | 127 | # Use select to wait for any of the device sockets to have data 128 | try: 129 | readable, _, _ = select.select(sockets, [], [], 0.1) 130 | except Exception as e: 131 | print(f"Device disconnected: {e}") 132 | # Find the invalid socket and remove it 133 | for sock in sockets: 134 | if sock.fileno() == -1: 135 | # reconnect 136 | device_info = config["TuyaDevices"][sockets.index(sock)] 137 | new_device = reconnect_device(device_info, sockets.index(sock), statuses, cool_down_time=5) 138 | if new_device: 139 | devices[sockets.index(sock)] = new_device 140 | sockets[sockets.index(sock)] = new_device.socket 141 | else: 142 | # Remove the invalid socket to avoid the negative file descriptor error 143 | sockets.remove(sock) 144 | continue 145 | 146 | if readable: 147 | # Process each socket with incoming data 148 | for sock in readable: 149 | # Find the corresponding device for this socket 150 | device = next((d for d in devices if d.socket == sock), None) 151 | if not device: 152 | continue 153 | 154 | updated_status = device.receive() 155 | 156 | if updated_status: 157 | print(f"UPDATE status from {device.id}: {updated_status}") 158 | index = devices.index(device) 159 | # We may only get one DPS, so just update that one item 160 | if "dps" in updated_status: 161 | for key in updated_status["dps"]: 162 | statuses[index]["status"][key] = updated_status["dps"][key] 163 | print(f" - Updated status for {device.id} DPS {key} to {updated_status['dps'][key]}") 164 | else: 165 | # Check if the device has disconnected 166 | if not device.socket or device.socket.fileno() == -1: 167 | # Device has disconnected 168 | print(f"Device {device.id} has disconnected.") 169 | # Reconnect logic with cool-down 170 | device_info = config["TuyaDevices"][devices.index(device)] 171 | new_device = reconnect_device(device_info, devices.index(device), statuses, cool_down_time=5) 172 | if new_device: 173 | devices[devices.index(device)] = new_device # Replace the disconnected device 174 | sockets[devices.index(device)] = new_device.socket # Update the socket list 175 | else: 176 | # Remove the invalid socket to avoid the negative file descriptor error 177 | sockets.remove(sock) 178 | else: 179 | print(f"Received empty status from {device.id}") 180 | 181 | # Initialize devices 182 | devices = [create_device(i) for i in config["TuyaDevices"]] 183 | 184 | # Start the status listener 185 | getDeviceStatuses(devices, config) 186 | --------------------------------------------------------------------------------