diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index ef8a23c..10e2c81 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -24,10 +24,11 @@ assignees: ''
Provide details about your environment.
-->
- Localtuya version:
-- Last working localtuya version (if known and relevant):
- Home Assistant Core version:
-- [] Are you using the Home Assistant Tuya Cloud component ?
-- [] Are you using the Tuya App in parallel ?
+- [] Does the device work using the Home Assistant Tuya Cloud component ?
+- [] Does the device work using the Tinytuya (https://github.com/jasonacox/tinytuya) command line tool ?
+- [] Was the device working with earlier versions of localtuya ? Which one?
+- [] Are you using the Tuya/SmartLife App in parallel ?
## Steps to reproduce
-```yaml
-
-```
## DP dump
-## Provide Home Assistant taceback/logs
+## Provide Home Assistant traceback/logs
-```txt
-
```
+put your log output between these markers
+```
+
+
## Additional information
diff --git a/README.md b/README.md
index a14e3a1..7f1c25f 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ The following Tuya device types are currently supported:
Energy monitoring (voltage, current, watts, etc.) is supported for compatible devices.
-> **Currently, only Tuya protocols 3.1 and 3.3 are supported (3.4 is not).**
+> **Currently, Tuya protocols from 3.1 to 3.4 are supported.**
This repository's development began as code from [@NameLessJedi](https://github.com/NameLessJedi), [@mileperhour](https://github.com/mileperhour) and [@TradeFace](https://github.com/TradeFace). Their code was then deeply refactored to provide proper integration with Home Assistant environment, adding config flow and other features. Refer to the "Thanks to" section below.
@@ -164,8 +164,11 @@ logger:
default: warning
logs:
custom_components.localtuya: debug
+ custom_components.localtuya.pytuya: debug
```
+Then, edit the device that is showing problems and check the "Enable debugging for this device" button.
+
# Notes:
* Do not declare anything as "tuya", such as by initiating a "switch.tuya". Using "tuya" launches Home Assistant's built-in, cloud-based Tuya integration in lieu of localtuya.
@@ -177,8 +180,6 @@ logger:
* Everything listed in https://github.com/rospogrigio/localtuya-homeassistant/issues/15
-* Support devices that use Tuya protocol v.3.4
-
# Thanks to:
NameLessJedi https://github.com/NameLessJedi/localtuya-homeassistant and mileperhour https://github.com/mileperhour/localtuya-homeassistant being the major sources of inspiration, and whose code for switches is substantially unchanged.
@@ -187,6 +188,8 @@ TradeFace, for being the only one to provide the correct code for communication
sean6541, for the working (standard) Python Handler for Tuya devices.
+jasonacox, for the TinyTuya project from where I could import the code to communicate with devices using protocol 3.4.
+
postlund, for the ideas, for coding 95% of the refactoring and boosting the quality of this repo to levels hard to imagine (by me, at least) and teaching me A LOT of how things work in Home Assistant.
diff --git a/custom_components/localtuya/common.py b/custom_components/localtuya/common.py
index aa8992f..85ed21c 100644
--- a/custom_components/localtuya/common.py
+++ b/custom_components/localtuya/common.py
@@ -25,18 +25,19 @@ from homeassistant.helpers.restore_state import RestoreEntity
from . import pytuya
from .const import (
+ ATTR_STATE,
ATTR_UPDATED_AT,
+ CONF_DEFAULT_VALUE,
+ CONF_ENABLE_DEBUG,
CONF_LOCAL_KEY,
CONF_MODEL,
+ CONF_PASSIVE_ENTITY,
CONF_PROTOCOL_VERSION,
+ CONF_RESET_DPIDS,
+ CONF_RESTORE_ON_RECONNECT,
DATA_CLOUD,
DOMAIN,
TUYA_DEVICES,
- CONF_DEFAULT_VALUE,
- ATTR_STATE,
- CONF_RESTORE_ON_RECONNECT,
- CONF_RESET_DPIDS,
- CONF_PASSIVE_ENTITY,
)
_LOGGER = logging.getLogger(__name__)
@@ -188,6 +189,7 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
self._dev_config_entry[CONF_DEVICE_ID],
self._local_key,
float(self._dev_config_entry[CONF_PROTOCOL_VERSION]),
+ self._dev_config_entry.get(CONF_ENABLE_DEBUG, False),
self,
)
self._interface.add_dps_to_request(self.dps_to_request)
diff --git a/custom_components/localtuya/config_flow.py b/custom_components/localtuya/config_flow.py
index 1eeb3b5..0099901 100644
--- a/custom_components/localtuya/config_flow.py
+++ b/custom_components/localtuya/config_flow.py
@@ -33,7 +33,9 @@ from .const import (
CONF_ADD_DEVICE,
CONF_DPS_STRINGS,
CONF_EDIT_DEVICE,
+ CONF_ENABLE_DEBUG,
CONF_LOCAL_KEY,
+ CONF_MANUAL_DPS,
CONF_MODEL,
CONF_NO_CLOUD,
CONF_PRODUCT_NAME,
@@ -45,7 +47,6 @@ from .const import (
DATA_DISCOVERY,
DOMAIN,
PLATFORMS,
- CONF_MANUAL_DPS,
)
from .discovery import discover
@@ -82,26 +83,17 @@ CLOUD_SETUP_SCHEMA = vol.Schema(
}
)
-CONFIGURE_DEVICE_SCHEMA = vol.Schema(
- {
- vol.Required(CONF_FRIENDLY_NAME): str,
- vol.Required(CONF_LOCAL_KEY): str,
- vol.Required(CONF_HOST): str,
- vol.Required(CONF_DEVICE_ID): str,
- vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]),
- vol.Optional(CONF_SCAN_INTERVAL): int,
- vol.Optional(CONF_MANUAL_DPS): str,
- vol.Optional(CONF_RESET_DPIDS): str,
- }
-)
DEVICE_SCHEMA = vol.Schema(
{
+ vol.Required(CONF_FRIENDLY_NAME): cv.string,
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_DEVICE_ID): cv.string,
vol.Required(CONF_LOCAL_KEY): cv.string,
- vol.Required(CONF_FRIENDLY_NAME): cv.string,
- vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]),
+ vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(
+ ["3.1", "3.2", "3.3", "3.4"]
+ ),
+ vol.Required(CONF_ENABLE_DEBUG, default=False): bool,
vol.Optional(CONF_SCAN_INTERVAL): int,
vol.Optional(CONF_MANUAL_DPS): cv.string,
vol.Optional(CONF_RESET_DPIDS): str,
@@ -141,13 +133,16 @@ def options_schema(entities):
]
return vol.Schema(
{
- vol.Required(CONF_FRIENDLY_NAME): str,
- vol.Required(CONF_HOST): str,
- vol.Required(CONF_LOCAL_KEY): str,
- vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]),
- vol.Optional(CONF_SCAN_INTERVAL): int,
- vol.Optional(CONF_MANUAL_DPS): str,
- vol.Optional(CONF_RESET_DPIDS): str,
+ vol.Required(CONF_FRIENDLY_NAME): cv.string,
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_LOCAL_KEY): cv.string,
+ vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(
+ ["3.1", "3.2", "3.3", "3.4"]
+ ),
+ vol.Required(CONF_ENABLE_DEBUG, default=False): bool,
+ vol.Optional(CONF_SCAN_INTERVAL): cv.string,
+ vol.Optional(CONF_MANUAL_DPS): cv.string,
+ vol.Optional(CONF_RESET_DPIDS): cv.string,
vol.Required(
CONF_ENTITIES, description={"suggested_value": entity_names}
): cv.multi_select(entity_names),
@@ -247,6 +242,7 @@ async def validate_input(hass: core.HomeAssistant, data):
data[CONF_DEVICE_ID],
data[CONF_LOCAL_KEY],
float(data[CONF_PROTOCOL_VERSION]),
+ data[CONF_ENABLE_DEBUG],
)
if CONF_RESET_DPIDS in data:
reset_ids_str = data[CONF_RESET_DPIDS].split(",")
@@ -564,6 +560,11 @@ class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow):
CONF_ENTITIES: [],
}
)
+ if len(user_input[CONF_ENTITIES]) == 0:
+ return self.async_abort(
+ reason="no_entities",
+ description_placeholders={},
+ )
if user_input[CONF_ENTITIES]:
entity_ids = [
int(entity.split(":")[0])
@@ -611,7 +612,7 @@ class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow):
if dev_id in cloud_devs:
defaults[CONF_LOCAL_KEY] = cloud_devs[dev_id].get(CONF_LOCAL_KEY)
defaults[CONF_FRIENDLY_NAME] = cloud_devs[dev_id].get(CONF_NAME)
- schema = schema_defaults(CONFIGURE_DEVICE_SCHEMA, **defaults)
+ schema = schema_defaults(DEVICE_SCHEMA, **defaults)
placeholders = {"for_device": ""}
diff --git a/custom_components/localtuya/const.py b/custom_components/localtuya/const.py
index 8010d18..3a6c252 100644
--- a/custom_components/localtuya/const.py
+++ b/custom_components/localtuya/const.py
@@ -28,6 +28,7 @@ ATTR_UPDATED_AT = "updated_at"
# config flow
CONF_LOCAL_KEY = "local_key"
+CONF_ENABLE_DEBUG = "enable_debug"
CONF_PROTOCOL_VERSION = "protocol_version"
CONF_DPS_STRINGS = "dps_strings"
CONF_MODEL = "model"
diff --git a/custom_components/localtuya/cover.py b/custom_components/localtuya/cover.py
index 3b6b86d..b45669c 100644
--- a/custom_components/localtuya/cover.py
+++ b/custom_components/localtuya/cover.py
@@ -108,13 +108,13 @@ class LocaltuyaCover(LocalTuyaEntity, CoverEntity):
def is_closed(self):
"""Return if the cover is closed or not."""
if self._config[CONF_POSITIONING_MODE] == COVER_MODE_NONE:
- return None
+ return False
if self._current_cover_position == 0:
return True
if self._current_cover_position == 100:
return False
- return None
+ return False
async def async_set_cover_position(self, **kwargs):
"""Move the cover to a specific position."""
diff --git a/custom_components/localtuya/fan.py b/custom_components/localtuya/fan.py
index 584ea84..32c3289 100644
--- a/custom_components/localtuya/fan.py
+++ b/custom_components/localtuya/fan.py
@@ -27,12 +27,12 @@ from .const import (
CONF_FAN_DIRECTION,
CONF_FAN_DIRECTION_FWD,
CONF_FAN_DIRECTION_REV,
+ CONF_FAN_DPS_TYPE,
CONF_FAN_ORDERED_LIST,
CONF_FAN_OSCILLATING_CONTROL,
CONF_FAN_SPEED_CONTROL,
CONF_FAN_SPEED_MAX,
CONF_FAN_SPEED_MIN,
- CONF_FAN_DPS_TYPE,
)
_LOGGER = logging.getLogger(__name__)
diff --git a/custom_components/localtuya/manifest.json b/custom_components/localtuya/manifest.json
index 10b1b5a..3f1e00b 100644
--- a/custom_components/localtuya/manifest.json
+++ b/custom_components/localtuya/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "localtuya",
"name": "LocalTuya integration",
- "version": "4.1.1",
+ "version": "5.0.0",
"documentation": "https://github.com/rospogrigio/localtuya/",
"dependencies": [],
"codeowners": [
diff --git a/custom_components/localtuya/number.py b/custom_components/localtuya/number.py
index 23d7ea9..917d3d0 100644
--- a/custom_components/localtuya/number.py
+++ b/custom_components/localtuya/number.py
@@ -7,14 +7,13 @@ from homeassistant.components.number import DOMAIN, NumberEntity
from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN
from .common import LocalTuyaEntity, async_setup_entry
-
from .const import (
- CONF_MIN_VALUE,
- CONF_MAX_VALUE,
CONF_DEFAULT_VALUE,
+ CONF_MAX_VALUE,
+ CONF_MIN_VALUE,
+ CONF_PASSIVE_ENTITY,
CONF_RESTORE_ON_RECONNECT,
CONF_STEPSIZE_VALUE,
- CONF_PASSIVE_ENTITY,
)
_LOGGER = logging.getLogger(__name__)
diff --git a/custom_components/localtuya/pytuya/__init__.py b/custom_components/localtuya/pytuya/__init__.py
index 9c3bd45..9668b2d 100644
--- a/custom_components/localtuya/pytuya/__init__.py
+++ b/custom_components/localtuya/pytuya/__init__.py
@@ -3,11 +3,8 @@
"""
Python module to interface with Tuya WiFi smart devices.
-Mostly derived from Shenzhen Xenon ESP8266MOD WiFi smart devices
-E.g. https://wikidevi.com/wiki/Xenon_SM-PW701U
-
-Author: clach04
-Maintained by: postlund
+Author: clach04, postlund
+Maintained by: rospogrigio
For more information see https://github.com/clach04/python-tuya
@@ -19,7 +16,7 @@ Classes
Functions
json = status() # returns json payload
- set_version(version) # 3.1 [default] or 3.3
+ set_version(version) # 3.1 [default], 3.2, 3.3 or 3.4
detect_available_dps() # returns a list of available dps provided by the device
update_dps(dps) # sends update dps command
add_dps_to_request(dp_index) # adds dp_index to the list of dps used by the
@@ -27,18 +24,21 @@ Functions
set_dp(on, dp_index) # Set value of any dps index.
-Credits
- * TuyaAPI https://github.com/codetheweb/tuyapi by codetheweb and blackrozes
- For protocol reverse engineering
- * PyTuya https://github.com/clach04/python-tuya by clach04
- The origin of this python module (now abandoned)
- * LocalTuya https://github.com/rospogrigio/localtuya-homeassistant by rospogrigio
- Updated pytuya to support devices with Device IDs of 22 characters
+ Credits
+ * TuyaAPI https://github.com/codetheweb/tuyapi by codetheweb and blackrozes
+ For protocol reverse engineering
+ * PyTuya https://github.com/clach04/python-tuya by clach04
+ The origin of this python module (now abandoned)
+ * Tuya Protocol 3.4 Support by uzlonewolf
+ Enhancement to TuyaMessage logic for multi-payload messages and Tuya Protocol 3.4 support
+ * TinyTuya https://github.com/jasonacox/tinytuya by jasonacox
+ Several CLI tools and code for Tuya devices
"""
import asyncio
import base64
import binascii
+import hmac
import json
import logging
import struct
@@ -46,73 +46,174 @@ import time
import weakref
from abc import ABC, abstractmethod
from collections import namedtuple
-from hashlib import md5
+from hashlib import md5, sha256
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
-version_tuple = (9, 0, 0)
+version_tuple = (10, 0, 0)
version = version_string = __version__ = "%d.%d.%d" % version_tuple
-__author__ = "postlund"
+__author__ = "rospogrigio"
_LOGGER = logging.getLogger(__name__)
-TuyaMessage = namedtuple("TuyaMessage", "seqno cmd retcode payload crc")
+# Tuya Packet Format
+TuyaHeader = namedtuple("TuyaHeader", "prefix seqno cmd length")
+MessagePayload = namedtuple("MessagePayload", "cmd payload")
+try:
+ TuyaMessage = namedtuple(
+ "TuyaMessage", "seqno cmd retcode payload crc crc_good", defaults=(True,)
+ )
+except Exception:
+ TuyaMessage = namedtuple("TuyaMessage", "seqno cmd retcode payload crc crc_good")
+
+# TinyTuya Error Response Codes
+ERR_JSON = 900
+ERR_CONNECT = 901
+ERR_TIMEOUT = 902
+ERR_RANGE = 903
+ERR_PAYLOAD = 904
+ERR_OFFLINE = 905
+ERR_STATE = 906
+ERR_FUNCTION = 907
+ERR_DEVTYPE = 908
+ERR_CLOUDKEY = 909
+ERR_CLOUDRESP = 910
+ERR_CLOUDTOKEN = 911
+ERR_PARAMS = 912
+ERR_CLOUD = 913
+
+error_codes = {
+ ERR_JSON: "Invalid JSON Response from Device",
+ ERR_CONNECT: "Network Error: Unable to Connect",
+ ERR_TIMEOUT: "Timeout Waiting for Device",
+ ERR_RANGE: "Specified Value Out of Range",
+ ERR_PAYLOAD: "Unexpected Payload from Device",
+ ERR_OFFLINE: "Network Error: Device Unreachable",
+ ERR_STATE: "Device in Unknown State",
+ ERR_FUNCTION: "Function Not Supported by Device",
+ ERR_DEVTYPE: "Device22 Detected: Retry Command",
+ ERR_CLOUDKEY: "Missing Tuya Cloud Key and Secret",
+ ERR_CLOUDRESP: "Invalid JSON Response from Cloud",
+ ERR_CLOUDTOKEN: "Unable to Get Cloud Token",
+ ERR_PARAMS: "Missing Function Parameters",
+ ERR_CLOUD: "Error Response from Tuya Cloud",
+ None: "Unknown Error",
+}
+
+
+class DecodeError(Exception):
+ """Specific Exception caused by decoding error."""
+
+ pass
+
+
+# Tuya Command Types
+# Reference:
+# https://github.com/tuya/tuya-iotos-embeded-sdk-wifi-ble-bk7231n/blob/master/sdk/include/lan_protocol.h
+AP_CONFIG = 0x01 # FRM_TP_CFG_WF # only used for ap 3.0 network config
+ACTIVE = 0x02 # FRM_TP_ACTV (discard) # WORK_MODE_CMD
+SESS_KEY_NEG_START = 0x03 # FRM_SECURITY_TYPE3 # negotiate session key
+SESS_KEY_NEG_RESP = 0x04 # FRM_SECURITY_TYPE4 # negotiate session key response
+SESS_KEY_NEG_FINISH = 0x05 # FRM_SECURITY_TYPE5 # finalize session key negotiation
+UNBIND = 0x06 # FRM_TP_UNBIND_DEV # DATA_QUERT_CMD - issue command
+CONTROL = 0x07 # FRM_TP_CMD # STATE_UPLOAD_CMD
+STATUS = 0x08 # FRM_TP_STAT_REPORT # STATE_QUERY_CMD
+HEART_BEAT = 0x09 # FRM_TP_HB
+DP_QUERY = 0x0A # 10 # FRM_QUERY_STAT # UPDATE_START_CMD - get data points
+QUERY_WIFI = 0x0B # 11 # FRM_SSID_QUERY (discard) # UPDATE_TRANS_CMD
+TOKEN_BIND = 0x0C # 12 # FRM_USER_BIND_REQ # GET_ONLINE_TIME_CMD - system time (GMT)
+CONTROL_NEW = 0x0D # 13 # FRM_TP_NEW_CMD # FACTORY_MODE_CMD
+ENABLE_WIFI = 0x0E # 14 # FRM_ADD_SUB_DEV_CMD # WIFI_TEST_CMD
+WIFI_INFO = 0x0F # 15 # FRM_CFG_WIFI_INFO
+DP_QUERY_NEW = 0x10 # 16 # FRM_QUERY_STAT_NEW
+SCENE_EXECUTE = 0x11 # 17 # FRM_SCENE_EXEC
+UPDATEDPS = 0x12 # 18 # FRM_LAN_QUERY_DP # Request refresh of DPS
+UDP_NEW = 0x13 # 19 # FR_TYPE_ENCRYPTION
+AP_CONFIG_NEW = 0x14 # 20 # FRM_AP_CFG_WF_V40
+BOARDCAST_LPV34 = 0x23 # 35 # FR_TYPE_BOARDCAST_LPV34
+LAN_EXT_STREAM = 0x40 # 64 # FRM_LAN_EXT_STREAM
-SET = "set"
-STATUS = "status"
-HEARTBEAT = "heartbeat"
-RESET = "reset"
-UPDATEDPS = "updatedps" # Request refresh of DPS
PROTOCOL_VERSION_BYTES_31 = b"3.1"
PROTOCOL_VERSION_BYTES_33 = b"3.3"
+PROTOCOL_VERSION_BYTES_34 = b"3.4"
-PROTOCOL_33_HEADER = PROTOCOL_VERSION_BYTES_33 + 12 * b"\x00"
-
-MESSAGE_HEADER_FMT = ">4I" # 4*uint32: prefix, seqno, cmd, length
+PROTOCOL_3x_HEADER = 12 * b"\x00"
+PROTOCOL_33_HEADER = PROTOCOL_VERSION_BYTES_33 + PROTOCOL_3x_HEADER
+PROTOCOL_34_HEADER = PROTOCOL_VERSION_BYTES_34 + PROTOCOL_3x_HEADER
+MESSAGE_HEADER_FMT = ">4I" # 4*uint32: prefix, seqno, cmd, length [, retcode]
MESSAGE_RECV_HEADER_FMT = ">5I" # 4*uint32: prefix, seqno, cmd, length, retcode
+MESSAGE_RETCODE_FMT = ">I" # retcode for received messages
MESSAGE_END_FMT = ">2I" # 2*uint32: crc, suffix
-
+MESSAGE_END_FMT_HMAC = ">32sI" # 32s:hmac, uint32:suffix
PREFIX_VALUE = 0x000055AA
+PREFIX_BIN = b"\x00\x00U\xaa"
SUFFIX_VALUE = 0x0000AA55
+SUFFIX_BIN = b"\x00\x00\xaaU"
+NO_PROTOCOL_HEADER_CMDS = [
+ DP_QUERY,
+ DP_QUERY_NEW,
+ UPDATEDPS,
+ HEART_BEAT,
+ SESS_KEY_NEG_START,
+ SESS_KEY_NEG_RESP,
+ SESS_KEY_NEG_FINISH,
+]
HEARTBEAT_INTERVAL = 10
# DPS that are known to be safe to use with update_dps (0x12) command
UPDATE_DPS_WHITELIST = [18, 19, 20] # Socket (Wi-Fi)
+# Tuya Device Dictionary - Command and Payload Overrides
# This is intended to match requests.json payload at
# https://github.com/codetheweb/tuyapi :
-# type_0a devices require the 0a command as the status request
-# type_0d devices require the 0d command as the status request, and the list of
-# dps used set to null in the request payload (see generate_payload method)
-
+# 'type_0a' devices require the 0a command for the DP_QUERY request
+# 'type_0d' devices require the 0d command for the DP_QUERY request and a list of
+# dps used set to Null in the request payload
# prefix: # Next byte is command byte ("hexByte") some zero padding, then length
# of remaining payload, i.e. command + suffix (unclear if multiple bytes used for
# length, zero padding implies could be more than one byte)
-PAYLOAD_DICT = {
+
+# Any command not defined in payload_dict will be sent as-is with a
+# payload of {"gwId": "", "devId": "", "uid": "", "t": ""}
+
+payload_dict = {
+ # Default Device
"type_0a": {
- STATUS: {"hexByte": 0x0A, "command": {"gwId": "", "devId": ""}},
- SET: {"hexByte": 0x07, "command": {"devId": "", "uid": "", "t": ""}},
- HEARTBEAT: {"hexByte": 0x09, "command": {}},
- UPDATEDPS: {"hexByte": 0x12, "command": {"dpId": [18, 19, 20]}},
- RESET: {
- "hexByte": 0x12,
- "command": {
- "gwId": "",
- "devId": "",
- "uid": "",
- "t": "",
- "dpId": [18, 19, 20],
- },
+ AP_CONFIG: { # [BETA] Set Control Values on Device
+ "command": {"gwId": "", "devId": "", "uid": "", "t": ""},
+ },
+ CONTROL: { # Set Control Values on Device
+ "command": {"devId": "", "uid": "", "t": ""},
+ },
+ STATUS: { # Get Status from Device
+ "command": {"gwId": "", "devId": ""},
+ },
+ HEART_BEAT: {"command": {"gwId": "", "devId": ""}},
+ DP_QUERY: { # Get Data Points from Device
+ "command": {"gwId": "", "devId": "", "uid": "", "t": ""},
+ },
+ CONTROL_NEW: {"command": {"devId": "", "uid": "", "t": ""}},
+ DP_QUERY_NEW: {"command": {"devId": "", "uid": "", "t": ""}},
+ UPDATEDPS: {"command": {"dpId": [18, 19, 20]}},
+ },
+ # Special Case Device "0d" - Some of these devices
+ # Require the 0d command as the DP_QUERY status request and the list of
+ # dps requested payload
+ "type_0d": {
+ DP_QUERY: { # Get Data Points from Device
+ "command_override": CONTROL_NEW, # Uses CONTROL_NEW command for some reason
+ "command": {"devId": "", "uid": "", "t": ""},
},
},
- "type_0d": {
- STATUS: {"hexByte": 0x0D, "command": {"devId": "", "uid": "", "t": ""}},
- SET: {"hexByte": 0x07, "command": {"devId": "", "uid": "", "t": ""}},
- HEARTBEAT: {"hexByte": 0x09, "command": {}},
- UPDATEDPS: {"hexByte": 0x12, "command": {"dpId": [18, 19, 20]}},
+ "v3.4": {
+ CONTROL: {
+ "command_override": CONTROL_NEW, # Uses CONTROL_NEW command
+ "command": {"protocol": 5, "t": "int", "data": ""},
+ },
+ DP_QUERY: {"command_override": DP_QUERY_NEW},
},
}
@@ -132,13 +233,17 @@ class ContextualLogger:
def __init__(self):
"""Initialize a new ContextualLogger."""
self._logger = None
+ self._enable_debug = False
- def set_logger(self, logger, device_id):
+ def set_logger(self, logger, device_id, enable_debug=False):
"""Set base logger to use."""
+ self._enable_debug = enable_debug
self._logger = TuyaLoggingAdapter(logger, {"device_id": device_id})
def debug(self, msg, *args):
"""Debug level log."""
+ if not self._enable_debug:
+ return
return self._logger.log(logging.DEBUG, msg, *args)
def info(self, msg, *args):
@@ -158,8 +263,9 @@ class ContextualLogger:
return self._logger.exception(msg, *args)
-def pack_message(msg):
+def pack_message(msg, hmac_key=None):
"""Pack a TuyaMessage into bytes."""
+ end_fmt = MESSAGE_END_FMT_HMAC if hmac_key else MESSAGE_END_FMT
# Create full message excluding CRC and suffix
buffer = (
struct.pack(
@@ -167,28 +273,106 @@ def pack_message(msg):
PREFIX_VALUE,
msg.seqno,
msg.cmd,
- len(msg.payload) + struct.calcsize(MESSAGE_END_FMT),
+ len(msg.payload) + struct.calcsize(end_fmt),
)
+ msg.payload
)
-
+ if hmac_key:
+ crc = hmac.new(hmac_key, buffer, sha256).digest()
+ else:
+ crc = binascii.crc32(buffer) & 0xFFFFFFFF
# Calculate CRC, add it together with suffix
- buffer += struct.pack(MESSAGE_END_FMT, binascii.crc32(buffer), SUFFIX_VALUE)
-
+ buffer += struct.pack(end_fmt, crc, SUFFIX_VALUE)
return buffer
-def unpack_message(data):
+def unpack_message(data, hmac_key=None, header=None, no_retcode=False, logger=None):
"""Unpack bytes into a TuyaMessage."""
- header_len = struct.calcsize(MESSAGE_RECV_HEADER_FMT)
- end_len = struct.calcsize(MESSAGE_END_FMT)
+ end_fmt = MESSAGE_END_FMT_HMAC if hmac_key else MESSAGE_END_FMT
+ # 4-word header plus return code
+ header_len = struct.calcsize(MESSAGE_HEADER_FMT)
+ retcode_len = 0 if no_retcode else struct.calcsize(MESSAGE_RETCODE_FMT)
+ end_len = struct.calcsize(end_fmt)
+ headret_len = header_len + retcode_len
- _, seqno, cmd, _, retcode = struct.unpack(
- MESSAGE_RECV_HEADER_FMT, data[:header_len]
+ if len(data) < headret_len + end_len:
+ logger.debug(
+ "unpack_message(): not enough data to unpack header! need %d but only have %d",
+ headret_len + end_len,
+ len(data),
+ )
+ raise DecodeError("Not enough data to unpack header")
+
+ if header is None:
+ header = parse_header(data)
+
+ if len(data) < header_len + header.length:
+ logger.debug(
+ "unpack_message(): not enough data to unpack payload! need %d but only have %d",
+ header_len + header.length,
+ len(data),
+ )
+ raise DecodeError("Not enough data to unpack payload")
+
+ retcode = (
+ 0
+ if no_retcode
+ else struct.unpack(MESSAGE_RETCODE_FMT, data[header_len:headret_len])[0]
)
- payload = data[header_len:-end_len]
- crc, _ = struct.unpack(MESSAGE_END_FMT, data[-end_len:])
- return TuyaMessage(seqno, cmd, retcode, payload, crc)
+ # the retcode is technically part of the payload, but strip it as we do not want it here
+ payload = data[header_len + retcode_len : header_len + header.length]
+ crc, suffix = struct.unpack(end_fmt, payload[-end_len:])
+
+ if hmac_key:
+ have_crc = hmac.new(
+ hmac_key, data[: (header_len + header.length) - end_len], sha256
+ ).digest()
+ else:
+ have_crc = (
+ binascii.crc32(data[: (header_len + header.length) - end_len]) & 0xFFFFFFFF
+ )
+
+ if suffix != SUFFIX_VALUE:
+ logger.debug("Suffix prefix wrong! %08X != %08X", suffix, SUFFIX_VALUE)
+
+ if crc != have_crc:
+ if hmac_key:
+ logger.debug(
+ "HMAC checksum wrong! %r != %r",
+ binascii.hexlify(have_crc),
+ binascii.hexlify(crc),
+ )
+ else:
+ logger.debug("CRC wrong! %08X != %08X", have_crc, crc)
+
+ return TuyaMessage(
+ header.seqno, header.cmd, retcode, payload[:-end_len], crc, crc == have_crc
+ )
+
+
+def parse_header(data):
+ """Unpack bytes into a TuyaHeader."""
+ header_len = struct.calcsize(MESSAGE_HEADER_FMT)
+
+ if len(data) < header_len:
+ raise DecodeError("Not enough data to unpack header")
+
+ prefix, seqno, cmd, payload_len = struct.unpack(
+ MESSAGE_HEADER_FMT, data[:header_len]
+ )
+
+ if prefix != PREFIX_VALUE:
+ # self.debug('Header prefix wrong! %08X != %08X', prefix, PREFIX_VALUE)
+ raise DecodeError("Header prefix wrong! %08X != %08X" % (prefix, PREFIX_VALUE))
+
+ # sanity check. currently the max payload length is somewhere around 300 bytes
+ if payload_len > 1000:
+ raise DecodeError(
+ "Header claims the packet size is over 1000 bytes! It is most likely corrupt. Claimed size: %d bytes"
+ % payload_len
+ )
+
+ return TuyaHeader(prefix, seqno, cmd, payload_len)
class AESCipher:
@@ -199,19 +383,22 @@ class AESCipher:
self.block_size = 16
self.cipher = Cipher(algorithms.AES(key), modes.ECB(), default_backend())
- def encrypt(self, raw, use_base64=True):
+ def encrypt(self, raw, use_base64=True, pad=True):
"""Encrypt data to be sent to device."""
encryptor = self.cipher.encryptor()
- crypted_text = encryptor.update(self._pad(raw)) + encryptor.finalize()
+ if pad:
+ raw = self._pad(raw)
+ crypted_text = encryptor.update(raw) + encryptor.finalize()
return base64.b64encode(crypted_text) if use_base64 else crypted_text
- def decrypt(self, enc, use_base64=True):
+ def decrypt(self, enc, use_base64=True, decode_text=True):
"""Decrypt data from device."""
if use_base64:
enc = base64.b64decode(enc)
decryptor = self.cipher.decryptor()
- return self._unpad(decryptor.update(enc) + decryptor.finalize()).decode()
+ raw = self._unpad(decryptor.update(enc) + decryptor.finalize())
+ return raw.decode("utf-8") if decode_text else raw
def _pad(self, data):
padnum = self.block_size - len(data) % self.block_size
@@ -225,18 +412,22 @@ class AESCipher:
class MessageDispatcher(ContextualLogger):
"""Buffer and dispatcher for Tuya messages."""
- # Heartbeats always respond with sequence number 0, so they can't be waited for like
- # other messages. This is a hack to allow waiting for heartbeats.
+ # Heartbeats on protocols < 3.3 respond with sequence number 0,
+ # so they can't be waited for like other messages.
+ # This is a hack to allow waiting for heartbeats.
HEARTBEAT_SEQNO = -100
RESET_SEQNO = -101
+ SESS_KEY_SEQNO = -102
- def __init__(self, dev_id, listener):
+ def __init__(self, dev_id, listener, protocol_version, local_key, enable_debug):
"""Initialize a new MessageBuffer."""
super().__init__()
self.buffer = b""
self.listeners = {}
self.listener = listener
- self.set_logger(_LOGGER, dev_id)
+ self.version = protocol_version
+ self.local_key = local_key
+ self.set_logger(_LOGGER, dev_id, enable_debug)
def abort(self):
"""Abort all waiting clients."""
@@ -248,12 +439,12 @@ class MessageDispatcher(ContextualLogger):
if isinstance(sem, asyncio.Semaphore):
sem.release()
- async def wait_for(self, seqno, timeout=5):
+ async def wait_for(self, seqno, cmd, timeout=5):
"""Wait for response to a sequence number to be received and return it."""
if seqno in self.listeners:
raise Exception(f"listener exists for {seqno}")
- self.debug("Waiting for sequence number %d", seqno)
+ self.debug("Command %d waiting for sequence number %d", cmd, seqno)
self.listeners[seqno] = asyncio.Semaphore(0)
try:
await asyncio.wait_for(self.listeners[seqno].acquire(), timeout=timeout)
@@ -273,54 +464,44 @@ class MessageDispatcher(ContextualLogger):
if len(self.buffer) < header_len:
break
- # Parse header and check if enough data according to length in header
- _, seqno, cmd, length, retcode = struct.unpack_from(
- MESSAGE_RECV_HEADER_FMT, self.buffer
+ header = parse_header(self.buffer)
+ hmac_key = self.local_key if self.version == 3.4 else None
+ msg = unpack_message(
+ self.buffer, header=header, hmac_key=hmac_key, logger=self
)
- if len(self.buffer[header_len - 4 :]) < length:
- break
-
- # length includes payload length, retcode, crc and suffix
- if (retcode & 0xFFFFFF00) != 0:
- payload_start = header_len - 4
- payload_length = length - struct.calcsize(MESSAGE_END_FMT)
- else:
- payload_start = header_len
- payload_length = length - 4 - struct.calcsize(MESSAGE_END_FMT)
- payload = self.buffer[payload_start : payload_start + payload_length]
-
- crc, _ = struct.unpack_from(
- MESSAGE_END_FMT,
- self.buffer[payload_start + payload_length : payload_start + length],
- )
-
- self.buffer = self.buffer[header_len - 4 + length :]
- self._dispatch(TuyaMessage(seqno, cmd, retcode, payload, crc))
+ self.buffer = self.buffer[header_len - 4 + header.length :]
+ self._dispatch(msg)
def _dispatch(self, msg):
"""Dispatch a message to someone that is listening."""
- self.debug("Dispatching message %s", msg)
+ self.debug("Dispatching message CMD %r %s", msg.cmd, msg)
if msg.seqno in self.listeners:
- self.debug("Dispatching sequence number %d", msg.seqno)
+ # self.debug("Dispatching sequence number %d", msg.seqno)
sem = self.listeners[msg.seqno]
if isinstance(sem, asyncio.Semaphore):
self.listeners[msg.seqno] = msg
sem.release()
else:
self.debug("Got additional message without request - skipping: %s", sem)
- elif msg.cmd == 0x09:
+ elif msg.cmd == HEART_BEAT:
self.debug("Got heartbeat response")
if self.HEARTBEAT_SEQNO in self.listeners:
sem = self.listeners[self.HEARTBEAT_SEQNO]
self.listeners[self.HEARTBEAT_SEQNO] = msg
sem.release()
- elif msg.cmd == 0x12:
+ elif msg.cmd == UPDATEDPS:
self.debug("Got normal updatedps response")
if self.RESET_SEQNO in self.listeners:
sem = self.listeners[self.RESET_SEQNO]
self.listeners[self.RESET_SEQNO] = msg
sem.release()
- elif msg.cmd == 0x08:
+ elif msg.cmd == SESS_KEY_NEG_RESP:
+ self.debug("Got key negotiation response")
+ if self.SESS_KEY_SEQNO in self.listeners:
+ sem = self.listeners[self.SESS_KEY_SEQNO]
+ self.listeners[self.SESS_KEY_SEQNO] = msg
+ sem.release()
+ elif msg.cmd == STATUS:
if self.RESET_SEQNO in self.listeners:
self.debug("Got reset status update")
sem = self.listeners[self.RESET_SEQNO]
@@ -330,12 +511,15 @@ class MessageDispatcher(ContextualLogger):
self.debug("Got status update")
self.listener(msg)
else:
- self.debug(
- "Got message type %d for unknown listener %d: %s",
- msg.cmd,
- msg.seqno,
- msg,
- )
+ if msg.cmd == CONTROL_NEW:
+ self.debug("Got ACK message for command %d: will ignore it", msg.cmd)
+ else:
+ self.error(
+ "Got message type %d for unknown listener %d: %s",
+ msg.cmd,
+ msg.seqno,
+ msg,
+ )
class TuyaListener(ABC):
@@ -363,7 +547,9 @@ class EmptyListener(TuyaListener):
class TuyaProtocol(asyncio.Protocol, ContextualLogger):
"""Implementation of the Tuya protocol."""
- def __init__(self, dev_id, local_key, protocol_version, on_connected, listener):
+ def __init__(
+ self, dev_id, local_key, protocol_version, enable_debug, on_connected, listener
+ ):
"""
Initialize a new TuyaInterface.
@@ -377,23 +563,59 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
"""
super().__init__()
self.loop = asyncio.get_running_loop()
- self.set_logger(_LOGGER, dev_id)
+ self.set_logger(_LOGGER, dev_id, enable_debug)
self.id = dev_id
self.local_key = local_key.encode("latin1")
- self.version = protocol_version
+ self.real_local_key = self.local_key
self.dev_type = "type_0a"
self.dps_to_request = {}
+
+ if protocol_version:
+ self.set_version(float(protocol_version))
+ else:
+ # make sure we call our set_version() and not a subclass since some of
+ # them (such as BulbDevice) make connections when called
+ TuyaProtocol.set_version(self, 3.1)
+
self.cipher = AESCipher(self.local_key)
- self.seqno = 0
+ self.seqno = 1
self.transport = None
self.listener = weakref.ref(listener)
- self.dispatcher = self._setup_dispatcher()
+ self.dispatcher = self._setup_dispatcher(enable_debug)
self.on_connected = on_connected
self.heartbeater = None
self.dps_cache = {}
+ self.local_nonce = b"0123456789abcdef" # not-so-random random key
+ self.remote_nonce = b""
- def _setup_dispatcher(self):
+ def set_version(self, protocol_version):
+ """Set the device version and eventually start available DPs detection."""
+ self.version = protocol_version
+ self.version_bytes = str(protocol_version).encode("latin1")
+ self.version_header = self.version_bytes + PROTOCOL_3x_HEADER
+ if protocol_version == 3.2: # 3.2 behaves like 3.3 with type_0d
+ # self.version = 3.3
+ self.dev_type = "type_0d"
+ elif protocol_version == 3.4:
+ self.dev_type = "v3.4"
+
+ def error_json(self, number=None, payload=None):
+ """Return error details in JSON."""
+ try:
+ spayload = json.dumps(payload)
+ # spayload = payload.replace('\"','').replace('\'','')
+ except Exception:
+ spayload = '""'
+
+ vals = (error_codes[number], str(number), spayload)
+ self.debug("ERROR %s - %s - payload: %s", *vals)
+
+ return json.loads('{ "Error":"%s", "Err":"%s", "Payload":%s }' % vals)
+
+ def _setup_dispatcher(self, enable_debug):
def _status_update(msg):
+ if msg.seqno > 0:
+ self.seqno = msg.seqno + 1
decoded_message = self._decode_payload(msg.payload)
if "dps" in decoded_message:
self.dps_cache.update(decoded_message["dps"])
@@ -402,7 +624,9 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
if listener is not None:
listener.status_updated(self.dps_cache)
- return MessageDispatcher(self.id, _status_update)
+ return MessageDispatcher(
+ self.id, _status_update, self.version, self.local_key, enable_debug
+ )
def connection_made(self, transport):
"""Did connect to the device."""
@@ -437,11 +661,13 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
def data_received(self, data):
"""Received data from device."""
+ # self.debug("received data=%r", binascii.hexlify(data))
self.dispatcher.add_data(data)
def connection_lost(self, exc):
"""Disconnected from device."""
self.debug("Connection lost: %s", exc)
+ self.real_local_key = self.local_key
try:
listener = self.listener and self.listener()
if listener is not None:
@@ -452,6 +678,7 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
async def close(self):
"""Close connection and abort all outstanding listeners."""
self.debug("Closing connection")
+ self.real_local_key = self.local_key
if self.heartbeater is not None:
self.heartbeater.cancel()
try:
@@ -467,31 +694,86 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
self.transport = None
transport.close()
+ async def exchange_quick(self, payload, recv_retries):
+ """Similar to exchange() but never retries sending and does not decode the response."""
+ if not self.transport:
+ self.debug(
+ "[" + self.id + "] send quick failed, could not get socket: %s", payload
+ )
+ return None
+ enc_payload = (
+ self._encode_message(payload)
+ if isinstance(payload, MessagePayload)
+ else payload
+ )
+ # self.debug("Quick-dispatching message %s, seqno %s", binascii.hexlify(enc_payload), self.seqno)
+
+ try:
+ self.transport.write(enc_payload)
+ except Exception:
+ # self._check_socket_close(True)
+ self.close()
+ return None
+ while recv_retries:
+ try:
+ seqno = MessageDispatcher.SESS_KEY_SEQNO
+ msg = await self.dispatcher.wait_for(seqno, payload.cmd)
+ # for 3.4 devices, we get the starting seqno with the SESS_KEY_NEG_RESP message
+ self.seqno = msg.seqno
+ except Exception:
+ msg = None
+ if msg and len(msg.payload) != 0:
+ return msg
+ recv_retries -= 1
+ if recv_retries == 0:
+ self.debug(
+ "received null payload (%r) but out of recv retries, giving up", msg
+ )
+ else:
+ self.debug(
+ "received null payload (%r), fetch new one - %s retries remaining",
+ msg,
+ recv_retries,
+ )
+ return None
+
async def exchange(self, command, dps=None):
"""Send and receive a message, returning response from device."""
+ if self.version == 3.4 and self.real_local_key == self.local_key:
+ self.debug("3.4 device: negotiating a new session key")
+ await self._negotiate_session_key()
+
self.debug(
"Sending command %s (device type: %s)",
command,
self.dev_type,
)
payload = self._generate_payload(command, dps)
+ real_cmd = payload.cmd
dev_type = self.dev_type
+ # self.debug("Exchange: payload %r %r", payload.cmd, payload.payload)
# Wait for special sequence number if heartbeat or reset
- seqno = self.seqno - 1
+ seqno = self.seqno
- if command == HEARTBEAT:
+ if payload.cmd == HEART_BEAT:
seqno = MessageDispatcher.HEARTBEAT_SEQNO
- elif command == RESET:
+ elif payload.cmd == UPDATEDPS:
seqno = MessageDispatcher.RESET_SEQNO
- self.transport.write(payload)
- msg = await self.dispatcher.wait_for(seqno)
+ enc_payload = self._encode_message(payload)
+ self.transport.write(enc_payload)
+ msg = await self.dispatcher.wait_for(seqno, payload.cmd)
if msg is None:
self.debug("Wait was aborted for seqno %d", seqno)
return None
# TODO: Verify stuff, e.g. CRC sequence number?
+ if real_cmd in [HEART_BEAT, CONTROL, CONTROL_NEW] and len(msg.payload) == 0:
+ # device may send messages with empty payload in response
+ # to a HEART_BEAT or CONTROL or CONTROL_NEW command: consider them an ACK
+ self.debug("ACK received for command %d: ignoring it", real_cmd)
+ return None
payload = self._decode_payload(msg.payload)
# Perform a new exchange (once) if we switched device type
@@ -507,21 +789,21 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
async def status(self):
"""Return device status."""
- status = await self.exchange(STATUS)
+ status = await self.exchange(DP_QUERY)
if status and "dps" in status:
self.dps_cache.update(status["dps"])
return self.dps_cache
async def heartbeat(self):
"""Send a heartbeat message."""
- return await self.exchange(HEARTBEAT)
+ return await self.exchange(HEART_BEAT)
async def reset(self, dpIds=None):
"""Send a reset message (3.3 only)."""
if self.version == 3.3:
self.dev_type = "type_0a"
self.debug("reset switching to dev_type %s", self.dev_type)
- return await self.exchange(RESET, dpIds)
+ return await self.exchange(UPDATEDPS, dpIds)
return True
@@ -532,7 +814,7 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
Args:
dps([int]): list of dps to update, default=detected&whitelisted
"""
- if self.version == 3.3:
+ if self.version in [3.2, 3.3]: # 3.2 behaves like 3.3 with type_0d
if dps is None:
if not self.dps_cache:
await self.detect_available_dps()
@@ -542,7 +824,8 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
dps = list(set(dps).intersection(set(UPDATE_DPS_WHITELIST)))
self.debug("updatedps() entry (dps %s, dps_cache %s)", dps, self.dps_cache)
payload = self._generate_payload(UPDATEDPS, dps)
- self.transport.write(payload)
+ enc_payload = self._encode_message(payload)
+ self.transport.write(enc_payload)
return True
async def set_dp(self, value, dp_index):
@@ -553,11 +836,11 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
dp_index(int): dps index to set
value: new value for the dps index
"""
- return await self.exchange(SET, {str(dp_index): value})
+ return await self.exchange(CONTROL, {str(dp_index): value})
async def set_dps(self, dps):
"""Set values for a set of datapoints."""
- return await self.exchange(SET, dps)
+ return await self.exchange(CONTROL, dps)
async def detect_available_dps(self):
"""Return which datapoints are supported by the device."""
@@ -594,38 +877,190 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
self.dps_to_request.update({str(index): None for index in dp_indicies})
def _decode_payload(self, payload):
- if not payload:
- payload = "{}"
- elif payload.startswith(b"{"):
- pass
- elif payload.startswith(PROTOCOL_VERSION_BYTES_31):
- payload = payload[len(PROTOCOL_VERSION_BYTES_31) :] # remove version header
- # remove (what I'm guessing, but not confirmed is) 16-bytes of MD5
- # hexdigest of payload
- payload = self.cipher.decrypt(payload[16:])
- elif self.version == 3.3:
- if self.dev_type != "type_0a" or payload.startswith(
- PROTOCOL_VERSION_BYTES_33
- ):
- payload = payload[len(PROTOCOL_33_HEADER) :]
- payload = self.cipher.decrypt(payload, False)
+ cipher = AESCipher(self.local_key)
+ if self.version == 3.4:
+ # 3.4 devices encrypt the version header in addition to the payload
+ try:
+ # self.debug("decrypting=%r", payload)
+ payload = cipher.decrypt(payload, False, decode_text=False)
+ except Exception:
+ self.debug("incomplete payload=%r (len:%d)", payload, len(payload))
+ return self.error_json(ERR_PAYLOAD)
+
+ # self.debug("decrypted 3.x payload=%r", payload)
+
+ if payload.startswith(PROTOCOL_VERSION_BYTES_31):
+ # Received an encrypted payload
+ # Remove version header
+ payload = payload[len(PROTOCOL_VERSION_BYTES_31) :]
+ # Decrypt payload
+ # Remove 16-bytes of MD5 hexdigest of payload
+ payload = cipher.decrypt(payload[16:])
+ elif self.version >= 3.2: # 3.2 or 3.3 or 3.4
+ # Trim header for non-default device type
+ if payload.startswith(self.version_bytes):
+ payload = payload[len(self.version_header) :]
+ # self.debug("removing 3.x=%r", payload)
+ elif self.dev_type == "type_0d" and (len(payload) & 0x0F) != 0:
+ payload = payload[len(self.version_header) :]
+ # self.debug("removing type_0d 3.x header=%r", payload)
+
+ if self.version != 3.4:
+ try:
+ # self.debug("decrypting=%r", payload)
+ payload = cipher.decrypt(payload, False)
+ except Exception:
+ self.debug("incomplete payload=%r (len:%d)", payload, len(payload))
+ return self.error_json(ERR_PAYLOAD)
+
+ # self.debug("decrypted 3.x payload=%r", payload)
+ # Try to detect if type_0d found
+
+ if not isinstance(payload, str):
+ try:
+ payload = payload.decode()
+ except Exception:
+ self.debug("payload was not string type and decoding failed")
+ return self.error_json(ERR_JSON, payload)
if "data unvalid" in payload:
self.dev_type = "type_0d"
self.debug(
- "switching to dev_type %s",
+ "'data unvalid' error detected: switching to dev_type %r",
self.dev_type,
)
return None
- else:
- raise Exception(f"Unexpected payload={payload}")
+ elif not payload.startswith(b"{"):
+ self.debug("Unexpected payload=%r", payload)
+ return self.error_json(ERR_PAYLOAD, payload)
if not isinstance(payload, str):
payload = payload.decode()
- self.debug("Decrypted payload: %s", payload)
- return json.loads(payload)
+ self.debug("Deciphered data = %r", payload)
+ try:
+ json_payload = json.loads(payload)
+ except Exception:
+ json_payload = self.error_json(ERR_JSON, payload)
- def _generate_payload(self, command, data=None):
+ # v3.4 stuffs it into {"data":{"dps":{"1":true}}, ...}
+ if (
+ "dps" not in json_payload
+ and "data" in json_payload
+ and "dps" in json_payload["data"]
+ ):
+ json_payload["dps"] = json_payload["data"]["dps"]
+
+ return json_payload
+
+ async def _negotiate_session_key(self):
+ self.local_key = self.real_local_key
+
+ rkey = await self.exchange_quick(
+ MessagePayload(SESS_KEY_NEG_START, self.local_nonce), 2
+ )
+ if not rkey or not isinstance(rkey, TuyaMessage) or len(rkey.payload) < 48:
+ # error
+ self.debug("session key negotiation failed on step 1")
+ return False
+
+ if rkey.cmd != SESS_KEY_NEG_RESP:
+ self.debug(
+ "session key negotiation step 2 returned wrong command: %d", rkey.cmd
+ )
+ return False
+
+ payload = rkey.payload
+ try:
+ # self.debug("decrypting %r using %r", payload, self.real_local_key)
+ cipher = AESCipher(self.real_local_key)
+ payload = cipher.decrypt(payload, False, decode_text=False)
+ except Exception:
+ self.debug(
+ "session key step 2 decrypt failed, payload=%r (len:%d)",
+ payload,
+ len(payload),
+ )
+ return False
+
+ self.debug("decrypted session key negotiation step 2: payload=%r", payload)
+
+ if len(payload) < 48:
+ self.debug("session key negotiation step 2 failed, too short response")
+ return False
+
+ self.remote_nonce = payload[:16]
+ hmac_check = hmac.new(self.local_key, self.local_nonce, sha256).digest()
+
+ if hmac_check != payload[16:48]:
+ self.debug(
+ "session key negotiation step 2 failed HMAC check! wanted=%r but got=%r",
+ binascii.hexlify(hmac_check),
+ binascii.hexlify(payload[16:48]),
+ )
+
+ # self.debug("session local nonce: %r remote nonce: %r", self.local_nonce, self.remote_nonce)
+ rkey_hmac = hmac.new(self.local_key, self.remote_nonce, sha256).digest()
+ await self.exchange_quick(MessagePayload(SESS_KEY_NEG_FINISH, rkey_hmac), None)
+
+ self.local_key = bytes(
+ [a ^ b for (a, b) in zip(self.local_nonce, self.remote_nonce)]
+ )
+ # self.debug("Session nonce XOR'd: %r" % self.local_key)
+
+ cipher = AESCipher(self.real_local_key)
+ self.local_key = self.dispatcher.local_key = cipher.encrypt(
+ self.local_key, False, pad=False
+ )
+ self.debug("Session key negotiate success! session key: %r", self.local_key)
+ return True
+
+ # adds protocol header (if needed) and encrypts
+ def _encode_message(self, msg):
+ hmac_key = None
+ payload = msg.payload
+ self.cipher = AESCipher(self.local_key)
+ if self.version == 3.4:
+ hmac_key = self.local_key
+ if msg.cmd not in NO_PROTOCOL_HEADER_CMDS:
+ # add the 3.x header
+ payload = self.version_header + payload
+ self.debug("final payload for cmd %r: %r", msg.cmd, payload)
+ payload = self.cipher.encrypt(payload, False)
+ elif self.version >= 3.2:
+ # expect to connect and then disconnect to set new
+ payload = self.cipher.encrypt(payload, False)
+ if msg.cmd not in NO_PROTOCOL_HEADER_CMDS:
+ # add the 3.x header
+ payload = self.version_header + payload
+ elif msg.cmd == CONTROL:
+ # need to encrypt
+ payload = self.cipher.encrypt(payload)
+ preMd5String = (
+ b"data="
+ + payload
+ + b"||lpv="
+ + PROTOCOL_VERSION_BYTES_31
+ + b"||"
+ + self.local_key
+ )
+ m = md5()
+ m.update(preMd5String)
+ hexdigest = m.hexdigest()
+ # some tuya libraries strip 8: to :24
+ payload = (
+ PROTOCOL_VERSION_BYTES_31
+ + hexdigest[8:][:16].encode("latin1")
+ + payload
+ )
+
+ self.cipher = None
+ msg = TuyaMessage(self.seqno, msg.cmd, 0, payload, 0, True)
+ self.seqno += 1 # increase message sequence number
+ buffer = pack_message(msg, hmac_key=hmac_key)
+ # self.debug("payload encrypted with key %r => %r", self.local_key, binascii.hexlify(buffer))
+ return buffer
+
+ def _generate_payload(self, command, data=None, gwId=None, devId=None, uid=None):
"""
Generate the payload to send.
@@ -634,58 +1069,81 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
This is one of the entries from payload_dict
data(dict, optional): The data to be send.
This is what will be passed via the 'dps' entry
+ gwId(str, optional): Will be used for gwId
+ devId(str, optional): Will be used for devId
+ uid(str, optional): Will be used for uid
"""
- cmd_data = PAYLOAD_DICT[self.dev_type][command]
- json_data = cmd_data["command"]
- command_hb = cmd_data["hexByte"]
+ json_data = command_override = None
+
+ if command in payload_dict[self.dev_type]:
+ if "command" in payload_dict[self.dev_type][command]:
+ json_data = payload_dict[self.dev_type][command]["command"]
+ if "command_override" in payload_dict[self.dev_type][command]:
+ command_override = payload_dict[self.dev_type][command][
+ "command_override"
+ ]
+
+ if self.dev_type != "type_0a":
+ if (
+ json_data is None
+ and command in payload_dict["type_0a"]
+ and "command" in payload_dict["type_0a"][command]
+ ):
+ json_data = payload_dict["type_0a"][command]["command"]
+ if (
+ command_override is None
+ and command in payload_dict["type_0a"]
+ and "command_override" in payload_dict["type_0a"][command]
+ ):
+ command_override = payload_dict["type_0a"][command]["command_override"]
+
+ if command_override is None:
+ command_override = command
+ if json_data is None:
+ # I have yet to see a device complain about included but unneeded attribs, but they *will*
+ # complain about missing attribs, so just include them all unless otherwise specified
+ json_data = {"gwId": "", "devId": "", "uid": "", "t": ""}
if "gwId" in json_data:
- json_data["gwId"] = self.id
+ if gwId is not None:
+ json_data["gwId"] = gwId
+ else:
+ json_data["gwId"] = self.id
if "devId" in json_data:
- json_data["devId"] = self.id
+ if devId is not None:
+ json_data["devId"] = devId
+ else:
+ json_data["devId"] = self.id
if "uid" in json_data:
- json_data["uid"] = self.id # still use id, no separate uid
+ if uid is not None:
+ json_data["uid"] = uid
+ else:
+ json_data["uid"] = self.id
if "t" in json_data:
- json_data["t"] = str(int(time.time()))
+ if json_data["t"] == "int":
+ json_data["t"] = int(time.time())
+ else:
+ json_data["t"] = str(int(time.time()))
if data is not None:
if "dpId" in json_data:
json_data["dpId"] = data
+ elif "data" in json_data:
+ json_data["data"] = {"dps": data}
else:
json_data["dps"] = data
- elif command_hb == 0x0D:
+ elif self.dev_type == "type_0d" and command == DP_QUERY:
json_data["dps"] = self.dps_to_request
- payload = json.dumps(json_data).replace(" ", "").encode("utf-8")
- self.debug("Send payload: %s", payload)
+ if json_data == "":
+ payload = ""
+ else:
+ payload = json.dumps(json_data)
+ # if spaces are not removed device does not respond!
+ payload = payload.replace(" ", "").encode("utf-8")
+ self.debug("Sending payload: %s", payload)
- if self.version == 3.3:
- payload = self.cipher.encrypt(payload, False)
- if command_hb not in [0x0A, 0x12]:
- # add the 3.3 header
- payload = PROTOCOL_33_HEADER + payload
- elif command == SET:
- payload = self.cipher.encrypt(payload)
- to_hash = (
- b"data="
- + payload
- + b"||lpv="
- + PROTOCOL_VERSION_BYTES_31
- + b"||"
- + self.local_key
- )
- hasher = md5()
- hasher.update(to_hash)
- hexdigest = hasher.hexdigest()
- payload = (
- PROTOCOL_VERSION_BYTES_31
- + hexdigest[8:][:16].encode("latin1")
- + payload
- )
-
- msg = TuyaMessage(self.seqno, command_hb, 0, payload, 0)
- self.seqno += 1
- return pack_message(msg)
+ return MessagePayload(command_override, payload)
def __repr__(self):
"""Return internal string representation of object."""
@@ -697,6 +1155,7 @@ async def connect(
device_id,
local_key,
protocol_version,
+ enable_debug,
listener=None,
port=6668,
timeout=5,
@@ -709,6 +1168,7 @@ async def connect(
device_id,
local_key,
protocol_version,
+ enable_debug,
on_connected,
listener or EmptyListener(),
),
diff --git a/custom_components/localtuya/select.py b/custom_components/localtuya/select.py
index f643e08..c9b1d1c 100644
--- a/custom_components/localtuya/select.py
+++ b/custom_components/localtuya/select.py
@@ -4,19 +4,15 @@ from functools import partial
import voluptuous as vol
from homeassistant.components.select import DOMAIN, SelectEntity
-from homeassistant.const import (
- CONF_DEVICE_CLASS,
- STATE_UNKNOWN,
-)
+from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN
from .common import LocalTuyaEntity, async_setup_entry
-
from .const import (
+ CONF_DEFAULT_VALUE,
CONF_OPTIONS,
CONF_OPTIONS_FRIENDLY,
- CONF_DEFAULT_VALUE,
- CONF_RESTORE_ON_RECONNECT,
CONF_PASSIVE_ENTITY,
+ CONF_RESTORE_ON_RECONNECT,
)
diff --git a/custom_components/localtuya/switch.py b/custom_components/localtuya/switch.py
index bc664bf..3776836 100644
--- a/custom_components/localtuya/switch.py
+++ b/custom_components/localtuya/switch.py
@@ -9,14 +9,14 @@ from .common import LocalTuyaEntity, async_setup_entry
from .const import (
ATTR_CURRENT,
ATTR_CURRENT_CONSUMPTION,
- ATTR_VOLTAGE,
ATTR_STATE,
+ ATTR_VOLTAGE,
CONF_CURRENT,
CONF_CURRENT_CONSUMPTION,
- CONF_VOLTAGE,
CONF_DEFAULT_VALUE,
- CONF_RESTORE_ON_RECONNECT,
CONF_PASSIVE_ENTITY,
+ CONF_RESTORE_ON_RECONNECT,
+ CONF_VOLTAGE,
)
_LOGGER = logging.getLogger(__name__)
diff --git a/custom_components/localtuya/translations/en.json b/custom_components/localtuya/translations/en.json
index 4b3ddb0..947141c 100644
--- a/custom_components/localtuya/translations/en.json
+++ b/custom_components/localtuya/translations/en.json
@@ -33,7 +33,8 @@
"options": {
"abort": {
"already_configured": "Device has already been configured.",
- "device_success": "Device {dev_name} successfully {action}."
+ "device_success": "Device {dev_name} successfully {action}.",
+ "no_entities": "Cannot remove all entities from a device.\nIf you want to delete a device, enter it in the Devices menu, click the 3 dots in the 'Device info' frame, and press the Delete button."
},
"error": {
"authentication_failed": "Failed to authenticate.\n{msg}",
@@ -95,6 +96,7 @@
"device_id": "Device ID",
"local_key": "Local key",
"protocol_version": "Protocol Version",
+ "enable_debug": "Enable debugging for this device (debug must be enabled also in configuration.yaml)",
"scan_interval": "Scan interval (seconds, only when not updating automatically)",
"entities": "Entities (uncheck an entity to remove it)",
"manual_dps_strings": "Manual DPS to add (separated by commas ',') - used when detection is not working (optional)",
diff --git a/custom_components/localtuya/translations/it.json b/custom_components/localtuya/translations/it.json
index faf4afa..9b05309 100644
--- a/custom_components/localtuya/translations/it.json
+++ b/custom_components/localtuya/translations/it.json
@@ -33,7 +33,8 @@
"options": {
"abort": {
"already_configured": "Il dispositivo è già stato configurato.",
- "device_success": "Dispositivo {dev_name} {action} con successo."
+ "device_success": "Dispositivo {dev_name} {action} con successo.",
+ "no_entities": "Non si possono rimuovere tutte le entities da un device.\nPer rimuovere un device, entrarci nel menu Devices, premere sui 3 punti nel riquadro 'Device info', e premere il pulsante Delete."
},
"error": {
"authentication_failed": "Autenticazione fallita. Errore:\n{msg}",
@@ -95,6 +96,7 @@
"device_id": "ID del dispositivo",
"local_key": "Chiave locale",
"protocol_version": "Versione del protocollo",
+ "enable_debug": "Abilita il debugging per questo device (il debug va abilitato anche in configuration.yaml)",
"scan_interval": "Intervallo di scansione (secondi, solo quando non si aggiorna automaticamente)",
"entities": "Entities (deseleziona un'entity per rimuoverla)"
}
diff --git a/custom_components/localtuya/translations/pt-BR.json b/custom_components/localtuya/translations/pt-BR.json
index a2feed4..ca5629c 100644
--- a/custom_components/localtuya/translations/pt-BR.json
+++ b/custom_components/localtuya/translations/pt-BR.json
@@ -33,7 +33,8 @@
"options": {
"abort": {
"already_configured": "O dispositivo já foi configurado.",
- "device_success": "Dispositivo {dev_name} {action} com sucesso."
+ "device_success": "Dispositivo {dev_name} {action} com sucesso.",
+ "no_entities": "Não é possível remover todas as entidades de um dispositivo.\nSe você deseja excluir um dispositivo, insira-o no menu Dispositivos, clique nos 3 pontos no quadro 'Informações do dispositivo' e pressione o botão Excluir."
},
"error": {
"authentication_failed": "Falha ao autenticar.\n{msg}",
@@ -95,6 +96,7 @@
"device_id": "ID do dispositivo",
"local_key": "Local key",
"protocol_version": "Versão do protocolo",
+ "enable_debug": "Ative a depuração para este dispositivo (a depuração também deve ser ativada em configuration.yaml)",
"scan_interval": "Intervalo de escaneamento (segundos, somente quando não estiver atualizando automaticamente)",
"entities": "Entidades (desmarque uma entidade para removê-la)"
}
diff --git a/info.md b/info.md
index c346691..eba3286 100644
--- a/info.md
+++ b/info.md
@@ -23,7 +23,7 @@ The following Tuya device types are currently supported:
Energy monitoring (voltage, current, watts, etc.) is supported for compatible devices.
-> **Currently, only Tuya protocols 3.1 and 3.3 are supported (3.4 is not).**
+> **Currently, Tuya protocols from 3.1 to 3.4 are supported.**
This repository's development began as code from [@NameLessJedi](https://github.com/NameLessJedi), [@mileperhour](https://github.com/mileperhour) and [@TradeFace](https://github.com/TradeFace). Their code was then deeply refactored to provide proper integration with Home Assistant environment, adding config flow and other features. Refer to the "Thanks to" section below.
@@ -164,8 +164,11 @@ logger:
default: warning
logs:
custom_components.localtuya: debug
+ custom_components.localtuya.pytuya: debug
```
+Then, edit the device that is showing problems and check the "Enable debugging for this device" button.
+
# Notes:
* Do not declare anything as "tuya", such as by initiating a "switch.tuya". Using "tuya" launches Home Assistant's built-in, cloud-based Tuya integration in lieu of localtuya.
@@ -177,16 +180,16 @@ logger:
* Everything listed in https://github.com/rospogrigio/localtuya-homeassistant/issues/15
-* Support devices that use Tuya protocol v.3.4
-
# Thanks to:
NameLessJedi https://github.com/NameLessJedi/localtuya-homeassistant and mileperhour https://github.com/mileperhour/localtuya-homeassistant being the major sources of inspiration, and whose code for switches is substantially unchanged.
-TradeFace, for being the only one to provide the correct code for communication with the cover (in particular, the 0x0d command for the status instead of the 0x0a, and related needs such as double reply to be received): https://github.com/TradeFace/tuya/
+TradeFace, for being the only one to provide the correct code for communication with the type_0d devices (in particular, the 0x0d command for the status instead of the 0x0a, and related needs such as double reply to be received): https://github.com/TradeFace/tuya/
sean6541, for the working (standard) Python Handler for Tuya devices.
+jasonacox, for the TinyTuya project from where I could import the code to communicate with devices using protocol 3.4.
+
postlund, for the ideas, for coding 95% of the refactoring and boosting the quality of this repo to levels hard to imagine (by me, at least) and teaching me A LOT of how things work in Home Assistant.
diff --git a/pylint.rc b/pylint.rc
index 4ec670e..223e881 100644
--- a/pylint.rc
+++ b/pylint.rc
@@ -171,10 +171,12 @@ disable=line-too-long,
deprecated-sys-function,
exception-escape,
comprehension-escape,
- unused-variable,
- invalid-name,
- dangerous-default-value,
- unreachable
+ unused-variable,
+ invalid-name,
+ dangerous-default-value,
+ unreachable,
+ unnecessary-pass,
+ broad-except
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
diff --git a/setup.cfg b/setup.cfg
index 562dc77..c4dd99f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,10 +1,10 @@
[flake8]
exclude = .git,.tox
-max-line-length = 88
+max-line-length = 120
ignore = E203, W503
[mypy]
-python_version = 3.7
+python_version = 3.9
ignore_errors = true
follow_imports = silent
ignore_missing_imports = true
diff --git a/tox.ini b/tox.ini
index f965d9f..2602e5e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -10,7 +10,7 @@ python =
3.9: clean, py39, lint, typing
[testenv]
-passenv = TOXENV CI
+passenv = TOXENV,CI
whitelist_externals =
true
setenv =
diff --git a/tuyadebug.tgz b/tuyadebug.tgz
index 35ee561..50c7a76 100644
Binary files a/tuyadebug.tgz and b/tuyadebug.tgz differ