├── 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 |
16 |
17 | ### Step 4- Select the device types and click the the "pencil icon" to edit.
18 |
19 |
20 |
21 | ### Step 5 - Select the "DP Instruction" box and "Save Configuration"
22 |
23 |
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 |
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 |
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 | 
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 | 
111 |
112 | 
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 |
--------------------------------------------------------------------------------