From 4a5bf8386786b8fbb70f8de7c5b2a2e1dfbcf3a5 Mon Sep 17 00:00:00 2001 From: rospogrigio Date: Sat, 7 Jan 2023 22:42:11 +0100 Subject: [PATCH 01/14] Introduced pytuya with support for 3.4 protocol --- .../localtuya/pytuya/__init__.py | 709 ++++++++++++++---- 1 file changed, 544 insertions(+), 165 deletions(-) diff --git a/custom_components/localtuya/pytuya/__init__.py b/custom_components/localtuya/pytuya/__init__.py index d36cd5e8e..a41753427 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 @@ 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 @@ 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,18 +46,58 @@ 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: + 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", +} SET = "set" STATUS = "status" @@ -65,58 +105,109 @@ RESET = "reset" UPDATEDPS = "updatedps" # Request refresh of DPS +# 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 + + 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": { - 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]}}, + DP_QUERY: { # Get Data Points from Device + "command_override": CONTROL_NEW, # Uses CONTROL_NEW command for some reason + "command": {"devId": "", "uid": "", "t": ""}, + }, }, + "v3.4": { + CONTROL: { + "command_override": CONTROL_NEW, # Uses CONTROL_NEW command + "command": {"protocol":5, "t": "int", "data": ""} + }, + DP_QUERY: { "command_override": DP_QUERY_NEW }, + } } + + class TuyaLoggingAdapter(logging.LoggerAdapter): """Adapter that adds device id to all log points.""" @@ -158,8 +249,9 @@ def exception(self, msg, *args): 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 +259,81 @@ 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 + + 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] + # 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) - _, seqno, cmd, _, retcode = struct.unpack( - MESSAGE_RECV_HEADER_FMT, data[:header_len] +def parse_header(data): + 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] ) - payload = data[header_len:-end_len] - crc, _ = struct.unpack(MESSAGE_END_FMT, data[-end_len:]) - return TuyaMessage(seqno, cmd, retcode, payload, crc) + + 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 +344,21 @@ def __init__(self, key): 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 @@ -229,13 +376,16 @@ class MessageDispatcher(ContextualLogger): # 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, version, local_key): """Initialize a new MessageBuffer.""" super().__init__() self.buffer = b"" self.listeners = {} self.listener = listener + self.version = version + self.local_key = local_key self.set_logger(_LOGGER, dev_id) def abort(self): @@ -248,12 +398,12 @@ def abort(self): 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,51 +423,39 @@ def add_data(self, data): 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 - ) - 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)) + 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); + 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] self.listeners[msg.seqno] = msg sem.release() - 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] @@ -327,12 +465,15 @@ def _dispatch(self, msg): 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): @@ -377,11 +518,12 @@ def __init__(self, dev_id, local_key, protocol_version, on_connected, listener): self.set_logger(_LOGGER, dev_id) self.id = dev_id self.local_key = local_key.encode("latin1") + self.real_local_key = self.local_key self.version = protocol_version self.dev_type = "type_0a" self.dps_to_request = {} 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() @@ -389,6 +531,40 @@ def __init__(self, dev_id, local_key, protocol_version, on_connected, listener): self.heartbeater = None self.dps_cache = {} + 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) + + def set_version(self, version): + self.version = version + self.version_bytes = str(version).encode('latin1') + self.version_header = self.version_bytes + PROTOCOL_3x_HEADER + if version == 3.2: # 3.2 behaves like 3.3 with type_0d + #self.version = 3.3 + self.dev_type="type_0d" + if self.dps_to_request == {}: + self.detect_available_dps() + elif version == 3.4: + self.dev_type = "v3.4" + elif self.dev_type == "v3.4": + self.dev_type = "default" + + def error_json(self, number=None, payload=None): + """Return error details in JSON""" + try: + spayload = json.dumps(payload) + # spayload = payload.replace('\"','').replace('\'','') + except: + 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): def _status_update(msg): decoded_message = self._decode_payload(msg.payload) @@ -399,7 +575,7 @@ def _status_update(msg): 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) def connection_made(self, transport): """Did connect to the device.""" @@ -434,11 +610,13 @@ async def heartbeat_loop(): 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: @@ -449,6 +627,7 @@ def connection_lost(self, exc): 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: @@ -464,31 +643,78 @@ async def close(self): self.transport = None transport.close() + # similar to exchange() but never retries sending and does not decode the response + async def exchange_quick(self, payload, recv_retries): + + 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 type(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: + # self._check_socket_close(True) + self.close() + return None + while recv_retries: + try: + #msg = await self._receive() + seqno = MessageDispatcher.SESS_KEY_SEQNO + # seqno = self.seqno - 1 + 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: + 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 == HEARTBEAT: seqno = MessageDispatcher.HEARTBEAT_SEQNO - elif command == RESET: + elif payload.cmd == RESET: 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 == CONTROL_NEW and len(msg.payload) == 0: + # device may send one or two messages with empty payload in response + # to a CONTROL_NEW command, consider it 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 @@ -504,7 +730,7 @@ async def exchange(self, command, dps=None): 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 @@ -539,7 +765,8 @@ async def update_dps(self, dps=None): 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): @@ -550,11 +777,11 @@ async def set_dp(self, value, dp_index): 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.""" @@ -591,38 +818,175 @@ def add_dps_to_request(self, dp_indicies): 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: + 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: + 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: + 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" + # set at least one DPS + self.dps_to_request = {"1": None} 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: + json_payload = self.error_json(ERR_JSON, payload) + + # 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_nonce = b'0123456789abcdef' # not-so-random random key + self.remote_nonce = b'' + 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 type(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: + 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 + ) - def _generate_payload(self, command, data=None): + 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. @@ -631,58 +995,73 @@ def _generate_payload(self, command, data=None): 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": ""} + cmd_data = "" 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 - ) + return MessagePayload(command_override, payload) - msg = TuyaMessage(self.seqno, command_hb, 0, payload, 0) - self.seqno += 1 - return pack_message(msg) def __repr__(self): """Return internal string representation of object.""" From 5fbf5b39b453ad5900922bd8f04f72c780e3cdd8 Mon Sep 17 00:00:00 2001 From: rospogrigio Date: Sat, 7 Jan 2023 22:44:27 +0100 Subject: [PATCH 02/14] Introduced 3.4 protocol option in config flow --- custom_components/localtuya/config_flow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/localtuya/config_flow.py b/custom_components/localtuya/config_flow.py index 1eeb3b5da..cea831422 100644 --- a/custom_components/localtuya/config_flow.py +++ b/custom_components/localtuya/config_flow.py @@ -88,7 +88,7 @@ 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.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.2", "3.3", "3.4"]), vol.Optional(CONF_SCAN_INTERVAL): int, vol.Optional(CONF_MANUAL_DPS): str, vol.Optional(CONF_RESET_DPIDS): str, @@ -101,7 +101,7 @@ 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.Optional(CONF_SCAN_INTERVAL): int, vol.Optional(CONF_MANUAL_DPS): cv.string, vol.Optional(CONF_RESET_DPIDS): str, @@ -144,7 +144,7 @@ def options_schema(entities): 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.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.2", "3.3", "3.4"]), vol.Optional(CONF_SCAN_INTERVAL): int, vol.Optional(CONF_MANUAL_DPS): str, vol.Optional(CONF_RESET_DPIDS): str, From 24853b5de7ba121951697a7198d75ea42e851db4 Mon Sep 17 00:00:00 2001 From: rospogrigio Date: Sat, 7 Jan 2023 22:59:08 +0100 Subject: [PATCH 03/14] Fixed HEARTBEAT command --- custom_components/localtuya/pytuya/__init__.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/custom_components/localtuya/pytuya/__init__.py b/custom_components/localtuya/pytuya/__init__.py index a41753427..06fc17032 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -99,12 +99,6 @@ None: "Unknown Error", } -SET = "set" -STATUS = "status" -HEARTBEAT = "heartbeat" -RESET = "reset" -UPDATEDPS = "updatedps" # Request refresh of DPS - # 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 @@ -697,9 +691,9 @@ async def exchange(self, command, dps=None): # Wait for special sequence number if heartbeat or reset seqno = self.seqno - if payload.cmd == HEARTBEAT: + if payload.cmd == HEART_BEAT: seqno = MessageDispatcher.HEARTBEAT_SEQNO - elif payload.cmd == RESET: + elif payload.cmd == UPDATEDPS: seqno = MessageDispatcher.RESET_SEQNO enc_payload = self._encode_message(payload) @@ -737,14 +731,14 @@ async def status(self): 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 From 9d94f1665d4ecb9e77f059dc9b7ebd94684a1525 Mon Sep 17 00:00:00 2001 From: rospogrigio Date: Sat, 7 Jan 2023 23:24:02 +0100 Subject: [PATCH 04/14] Added debugging --- custom_components/localtuya/pytuya/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/localtuya/pytuya/__init__.py b/custom_components/localtuya/pytuya/__init__.py index 06fc17032..1241e0282 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -1052,7 +1052,7 @@ def _generate_payload(self, command, data=None, gwId=None, devId=None, uid=None) 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) + self.debug("Sending payload: %s", payload) return MessagePayload(command_override, payload) From 9efbcce0e4762112dc634d4d6e0a3d337db2e8f1 Mon Sep 17 00:00:00 2001 From: rospogrigio Date: Sun, 8 Jan 2023 07:42:53 +0100 Subject: [PATCH 05/14] Fixed requested DPs for type_0d devices --- custom_components/localtuya/pytuya/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/custom_components/localtuya/pytuya/__init__.py b/custom_components/localtuya/pytuya/__init__.py index 1241e0282..32dc744c7 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -704,7 +704,7 @@ async def exchange(self, command, dps=None): return None # TODO: Verify stuff, e.g. CRC sequence number? - if real_cmd == CONTROL_NEW and len(msg.payload) == 0: + if real_cmd in [HEART_BEAT, CONTROL_NEW] and len(msg.payload) == 0: # device may send one or two messages with empty payload in response # to a CONTROL_NEW command, consider it an ACK self.debug("ACK received for command %d: ignoring it", real_cmd) @@ -860,8 +860,6 @@ def _decode_payload(self, payload): return self.error_json(ERR_JSON, payload) if "data unvalid" in payload: self.dev_type = "type_0d" - # set at least one DPS - self.dps_to_request = {"1": None} self.debug( "'data unvalid' error detected: switching to dev_type %r", self.dev_type, From f23a945f5edb1a801bc6d9f14e02272292a558eb Mon Sep 17 00:00:00 2001 From: rospogrigio Date: Mon, 9 Jan 2023 01:31:28 +0100 Subject: [PATCH 06/14] Fixed negotiation and sequence numbers for protocol 3.4 --- custom_components/localtuya/pytuya/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/custom_components/localtuya/pytuya/__init__.py b/custom_components/localtuya/pytuya/__init__.py index 32dc744c7..3f24d50b5 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -366,8 +366,9 @@ def _unpad(data): 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 @@ -418,7 +419,7 @@ def add_data(self, data): break header = parse_header(self.buffer) - hmac_key = self.local_key if self.version == '3.4' else None + 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); self.buffer = self.buffer[header_len - 4 + header.length :] self._dispatch(msg) @@ -561,6 +562,8 @@ def error_json(self, number=None, payload=None): def _setup_dispatcher(self): def _status_update(msg): + if self.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"]) From ecf66f76e624aa5888b34862b385f15688748ac5 Mon Sep 17 00:00:00 2001 From: rospogrigio Date: Mon, 9 Jan 2023 01:49:51 +0100 Subject: [PATCH 07/14] Fixed sequence numbering on status update --- custom_components/localtuya/pytuya/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/localtuya/pytuya/__init__.py b/custom_components/localtuya/pytuya/__init__.py index 3f24d50b5..259ed571d 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -562,7 +562,7 @@ def error_json(self, number=None, payload=None): def _setup_dispatcher(self): def _status_update(msg): - if self.seqno > 0: + if msg.seqno > 0: self.seqno = msg.seqno + 1 decoded_message = self._decode_payload(msg.payload) if "dps" in decoded_message: @@ -707,9 +707,9 @@ async def exchange(self, command, dps=None): return None # TODO: Verify stuff, e.g. CRC sequence number? - if real_cmd in [HEART_BEAT, CONTROL_NEW] and len(msg.payload) == 0: - # device may send one or two messages with empty payload in response - # to a CONTROL_NEW command, consider it an ACK + 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) From 966c3ec536a144c2072926959f66838375dd16d9 Mon Sep 17 00:00:00 2001 From: rospogrigio Date: Mon, 9 Jan 2023 23:45:06 +0100 Subject: [PATCH 08/14] Fixed tox issues --- custom_components/localtuya/config_flow.py | 12 +- .../localtuya/pytuya/__init__.py | 316 +++++++++++------- pylint.rc | 10 +- setup.cfg | 4 +- 4 files changed, 215 insertions(+), 127 deletions(-) diff --git a/custom_components/localtuya/config_flow.py b/custom_components/localtuya/config_flow.py index cea831422..761a241f9 100644 --- a/custom_components/localtuya/config_flow.py +++ b/custom_components/localtuya/config_flow.py @@ -88,7 +88,9 @@ 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.2", "3.3", "3.4"]), + vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In( + ["3.1", "3.2", "3.3", "3.4"] + ), vol.Optional(CONF_SCAN_INTERVAL): int, vol.Optional(CONF_MANUAL_DPS): str, vol.Optional(CONF_RESET_DPIDS): str, @@ -101,7 +103,9 @@ 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.2", "3.3", "3.4"]), + vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In( + ["3.1", "3.2", "3.3", "3.4"] + ), vol.Optional(CONF_SCAN_INTERVAL): int, vol.Optional(CONF_MANUAL_DPS): cv.string, vol.Optional(CONF_RESET_DPIDS): str, @@ -144,7 +148,9 @@ def options_schema(entities): 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.2", "3.3", "3.4"]), + vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In( + ["3.1", "3.2", "3.3", "3.4"] + ), vol.Optional(CONF_SCAN_INTERVAL): int, vol.Optional(CONF_MANUAL_DPS): str, vol.Optional(CONF_RESET_DPIDS): str, diff --git a/custom_components/localtuya/pytuya/__init__.py b/custom_components/localtuya/pytuya/__init__.py index 259ed571d..55a97e11e 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -46,7 +46,7 @@ import weakref from abc import ABC, abstractmethod from collections import namedtuple -from hashlib import md5,sha256 +from hashlib import md5, sha256 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -58,11 +58,13 @@ _LOGGER = logging.getLogger(__name__) # Tuya Packet Format -TuyaHeader = namedtuple('TuyaHeader', 'prefix seqno cmd length') +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: + 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 @@ -99,30 +101,38 @@ 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 +# 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 +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 PROTOCOL_VERSION_BYTES_31 = b"3.1" @@ -141,7 +151,15 @@ 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 ] +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 @@ -193,15 +211,13 @@ "v3.4": { CONTROL: { "command_override": CONTROL_NEW, # Uses CONTROL_NEW command - "command": {"protocol":5, "t": "int", "data": ""} - }, - DP_QUERY: { "command_override": DP_QUERY_NEW }, - } + "command": {"protocol": 5, "t": "int", "data": ""}, + }, + DP_QUERY: {"command_override": DP_QUERY_NEW}, + }, } - - class TuyaLoggingAdapter(logging.LoggerAdapter): """Adapter that adds device id to all log points.""" @@ -243,7 +259,7 @@ def exception(self, msg, *args): return self._logger.exception(msg, *args) -def pack_message(msg,hmac_key=None): +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 @@ -262,9 +278,7 @@ def pack_message(msg,hmac_key=None): else: crc = binascii.crc32(buffer) & 0xFFFFFFFF # Calculate CRC, add it together with suffix - buffer += struct.pack( - end_fmt, crc, SUFFIX_VALUE - ) + buffer += struct.pack(end_fmt, crc, SUFFIX_VALUE) return buffer @@ -277,55 +291,82 @@ def unpack_message(data, hmac_key=None, header=None, no_retcode=False, logger=No end_len = struct.calcsize(end_fmt) headret_len = header_len + retcode_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 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') + 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] + retcode = ( + 0 + if no_retcode + else struct.unpack(MESSAGE_RETCODE_FMT, data[header_len:headret_len])[0] + ) # 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] + 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() + 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 + 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) + 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)) + logger.debug( + "HMAC checksum wrong! %r != %r", + binascii.hexlify(have_crc), + binascii.hexlify(crc), + ) else: - logger.debug('CRC wrong! %08X != %08X', have_crc, crc) + logger.debug("CRC wrong! %08X != %08X", have_crc, crc) + + return TuyaMessage( + header.seqno, header.cmd, retcode, payload[:-end_len], crc, crc == have_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') + 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)) + # 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) + 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) @@ -341,7 +382,8 @@ def __init__(self, key): def encrypt(self, raw, use_base64=True, pad=True): """Encrypt data to be sent to device.""" encryptor = self.cipher.encryptor() - if pad: raw = self._pad(raw) + if pad: + raw = self._pad(raw) crypted_text = encryptor.update(raw) + encryptor.finalize() return base64.b64encode(crypted_text) if use_base64 else crypted_text @@ -373,13 +415,13 @@ class MessageDispatcher(ContextualLogger): RESET_SEQNO = -101 SESS_KEY_SEQNO = -102 - def __init__(self, dev_id, listener, version, local_key): + def __init__(self, dev_id, listener, protocol_version, local_key): """Initialize a new MessageBuffer.""" super().__init__() self.buffer = b"" self.listeners = {} self.listener = listener - self.version = version + self.version = protocol_version self.local_key = local_key self.set_logger(_LOGGER, dev_id) @@ -420,7 +462,9 @@ def add_data(self, data): 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); + msg = unpack_message( + self.buffer, header=header, hmac_key=hmac_key, logger=self + ) self.buffer = self.buffer[header_len - 4 + header.length :] self._dispatch(msg) @@ -514,7 +558,6 @@ def __init__(self, dev_id, local_key, protocol_version, on_connected, listener): self.id = dev_id self.local_key = local_key.encode("latin1") self.real_local_key = self.local_key - self.version = protocol_version self.dev_type = "type_0a" self.dps_to_request = {} self.cipher = AESCipher(self.local_key) @@ -525,6 +568,8 @@ def __init__(self, dev_id, local_key, protocol_version, on_connected, listener): 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"" if protocol_version: self.set_version(float(protocol_version)) @@ -533,26 +578,27 @@ def __init__(self, dev_id, local_key, protocol_version, on_connected, listener): # them (such as BulbDevice) make connections when called TuyaProtocol.set_version(self, 3.1) - def set_version(self, version): - self.version = version - self.version_bytes = str(version).encode('latin1') + 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 version == 3.2: # 3.2 behaves like 3.3 with type_0d - #self.version = 3.3 - self.dev_type="type_0d" - if self.dps_to_request == {}: - self.detect_available_dps() - elif version == 3.4: + if protocol_version == 3.2: # 3.2 behaves like 3.3 with type_0d + # self.version = 3.3 + self.dev_type = "type_0d" + if self.dps_to_request == {}: + self.detect_available_dps() + elif protocol_version == 3.4: self.dev_type = "v3.4" elif self.dev_type == "v3.4": self.dev_type = "default" def error_json(self, number=None, payload=None): - """Return error details in JSON""" + """Return error details in JSON.""" try: spayload = json.dumps(payload) # spayload = payload.replace('\"','').replace('\'','') - except: + except Exception: spayload = '""' vals = (error_codes[number], str(number), spayload) @@ -640,43 +686,51 @@ async def close(self): self.transport = None transport.close() - # similar to exchange() but never retries sending and does not decode the response 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) + self.debug( + "[" + self.id + "] send quick failed, could not get socket: %s", payload + ) return None - enc_payload = self._encode_message(payload) if type(payload) == MessagePayload else payload + 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: + except Exception: # self._check_socket_close(True) self.close() return None while recv_retries: try: - #msg = await self._receive() seqno = MessageDispatcher.SESS_KEY_SEQNO - # seqno = self.seqno - 1 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: + 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) + 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) + 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() @@ -701,7 +755,7 @@ async def exchange(self, command, dps=None): enc_payload = self._encode_message(payload) self.transport.write(enc_payload) - msg = await self.dispatcher.wait_for(seqno, payload.cmd ) + msg = await self.dispatcher.wait_for(seqno, payload.cmd) if msg is None: self.debug("Wait was aborted for seqno %d", seqno) return None @@ -822,7 +876,7 @@ def _decode_payload(self, payload): try: # self.debug("decrypting=%r", payload) payload = cipher.decrypt(payload, False, decode_text=False) - except: + except Exception: self.debug("incomplete payload=%r (len:%d)", payload, len(payload)) return self.error_json(ERR_PAYLOAD) @@ -835,9 +889,9 @@ def _decode_payload(self, payload): # 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 + 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 ): + 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: @@ -848,7 +902,7 @@ def _decode_payload(self, payload): try: # self.debug("decrypting=%r", payload) payload = cipher.decrypt(payload, False) - except: + except Exception: self.debug("incomplete payload=%r (len:%d)", payload, len(payload)) return self.error_json(ERR_PAYLOAD) @@ -858,7 +912,7 @@ def _decode_payload(self, payload): if not isinstance(payload, str): try: payload = payload.decode() - except: + except Exception: self.debug("payload was not string type and decoding failed") return self.error_json(ERR_JSON, payload) if "data unvalid" in payload: @@ -877,28 +931,34 @@ def _decode_payload(self, payload): self.debug("Deciphered data = %r", payload) try: json_payload = json.loads(payload) - except: + except Exception: json_payload = self.error_json(ERR_JSON, payload) # 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'] + 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_nonce = b'0123456789abcdef' # not-so-random random key - self.remote_nonce = b'' 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 type(rkey) != TuyaMessage or len(rkey.payload) < 48: + 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) + self.debug( + "session key negotiation step 2 returned wrong command: %d", rkey.cmd + ) return False payload = rkey.payload @@ -906,8 +966,12 @@ async def _negotiate_session_key(self): # 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: - self.debug("session key step 2 decrypt failed, payload=%r (len:%d)", payload, len(payload)) + 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) @@ -920,23 +984,31 @@ async def _negotiate_session_key(self): 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 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 ) + 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.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.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 ): + def _encode_message(self, msg): hmac_key = None payload = msg.payload self.cipher = AESCipher(self.local_key) @@ -945,7 +1017,7 @@ def _encode_message( self, msg ): 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) + 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 @@ -977,7 +1049,7 @@ def _encode_message( self, msg ): 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) + buffer = pack_message(msg, hmac_key=hmac_key) # self.debug("payload encrypted with key %r => %r", self.local_key, binascii.hexlify(buffer)) return buffer @@ -997,16 +1069,26 @@ def _generate_payload(self, command, data=None, gwId=None, devId=None, uid=None) 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" 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 @@ -1014,7 +1096,6 @@ def _generate_payload(self, command, data=None, gwId=None, devId=None, uid=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": ""} - cmd_data = "" if "gwId" in json_data: if gwId is not None: @@ -1032,7 +1113,7 @@ def _generate_payload(self, command, data=None, gwId=None, devId=None, uid=None) else: json_data["uid"] = self.id if "t" in json_data: - if json_data['t'] == "int": + if json_data["t"] == "int": json_data["t"] = int(time.time()) else: json_data["t"] = str(int(time.time())) @@ -1057,7 +1138,6 @@ def _generate_payload(self, command, data=None, gwId=None, devId=None, uid=None) return MessagePayload(command_override, payload) - def __repr__(self): """Return internal string representation of object.""" return self.id diff --git a/pylint.rc b/pylint.rc index 4ec670e99..223e8810b 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 562dc776b..c4dd99f36 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 From f990e848a5ade1bd44a98b1c80129dd278c94fd0 Mon Sep 17 00:00:00 2001 From: rospogrigio Date: Mon, 9 Jan 2023 23:45:06 +0100 Subject: [PATCH 09/14] Fixed tox issues --- custom_components/localtuya/common.py | 10 +- custom_components/localtuya/config_flow.py | 14 +- custom_components/localtuya/fan.py | 2 +- custom_components/localtuya/number.py | 7 +- .../localtuya/pytuya/__init__.py | 316 +++++++++++------- custom_components/localtuya/select.py | 10 +- custom_components/localtuya/switch.py | 6 +- pylint.rc | 10 +- setup.cfg | 4 +- 9 files changed, 231 insertions(+), 148 deletions(-) diff --git a/custom_components/localtuya/common.py b/custom_components/localtuya/common.py index aa8992fd5..76521eb94 100644 --- a/custom_components/localtuya/common.py +++ b/custom_components/localtuya/common.py @@ -25,18 +25,18 @@ from . import pytuya from .const import ( + ATTR_STATE, ATTR_UPDATED_AT, + CONF_DEFAULT_VALUE, 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__) diff --git a/custom_components/localtuya/config_flow.py b/custom_components/localtuya/config_flow.py index cea831422..0258a728a 100644 --- a/custom_components/localtuya/config_flow.py +++ b/custom_components/localtuya/config_flow.py @@ -34,6 +34,7 @@ CONF_DPS_STRINGS, CONF_EDIT_DEVICE, CONF_LOCAL_KEY, + CONF_MANUAL_DPS, CONF_MODEL, CONF_NO_CLOUD, CONF_PRODUCT_NAME, @@ -45,7 +46,6 @@ DATA_DISCOVERY, DOMAIN, PLATFORMS, - CONF_MANUAL_DPS, ) from .discovery import discover @@ -88,7 +88,9 @@ 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.2", "3.3", "3.4"]), + vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In( + ["3.1", "3.2", "3.3", "3.4"] + ), vol.Optional(CONF_SCAN_INTERVAL): int, vol.Optional(CONF_MANUAL_DPS): str, vol.Optional(CONF_RESET_DPIDS): str, @@ -101,7 +103,9 @@ 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.2", "3.3", "3.4"]), + vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In( + ["3.1", "3.2", "3.3", "3.4"] + ), vol.Optional(CONF_SCAN_INTERVAL): int, vol.Optional(CONF_MANUAL_DPS): cv.string, vol.Optional(CONF_RESET_DPIDS): str, @@ -144,7 +148,9 @@ def options_schema(entities): 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.2", "3.3", "3.4"]), + vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In( + ["3.1", "3.2", "3.3", "3.4"] + ), vol.Optional(CONF_SCAN_INTERVAL): int, vol.Optional(CONF_MANUAL_DPS): str, vol.Optional(CONF_RESET_DPIDS): str, diff --git a/custom_components/localtuya/fan.py b/custom_components/localtuya/fan.py index 584ea84c1..32c32899c 100644 --- a/custom_components/localtuya/fan.py +++ b/custom_components/localtuya/fan.py @@ -27,12 +27,12 @@ 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/number.py b/custom_components/localtuya/number.py index 23d7ea9a0..917d3d00b 100644 --- a/custom_components/localtuya/number.py +++ b/custom_components/localtuya/number.py @@ -7,14 +7,13 @@ 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 259ed571d..55a97e11e 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -46,7 +46,7 @@ import weakref from abc import ABC, abstractmethod from collections import namedtuple -from hashlib import md5,sha256 +from hashlib import md5, sha256 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -58,11 +58,13 @@ _LOGGER = logging.getLogger(__name__) # Tuya Packet Format -TuyaHeader = namedtuple('TuyaHeader', 'prefix seqno cmd length') +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: + 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 @@ -99,30 +101,38 @@ 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 +# 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 +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 PROTOCOL_VERSION_BYTES_31 = b"3.1" @@ -141,7 +151,15 @@ 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 ] +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 @@ -193,15 +211,13 @@ "v3.4": { CONTROL: { "command_override": CONTROL_NEW, # Uses CONTROL_NEW command - "command": {"protocol":5, "t": "int", "data": ""} - }, - DP_QUERY: { "command_override": DP_QUERY_NEW }, - } + "command": {"protocol": 5, "t": "int", "data": ""}, + }, + DP_QUERY: {"command_override": DP_QUERY_NEW}, + }, } - - class TuyaLoggingAdapter(logging.LoggerAdapter): """Adapter that adds device id to all log points.""" @@ -243,7 +259,7 @@ def exception(self, msg, *args): return self._logger.exception(msg, *args) -def pack_message(msg,hmac_key=None): +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 @@ -262,9 +278,7 @@ def pack_message(msg,hmac_key=None): else: crc = binascii.crc32(buffer) & 0xFFFFFFFF # Calculate CRC, add it together with suffix - buffer += struct.pack( - end_fmt, crc, SUFFIX_VALUE - ) + buffer += struct.pack(end_fmt, crc, SUFFIX_VALUE) return buffer @@ -277,55 +291,82 @@ def unpack_message(data, hmac_key=None, header=None, no_retcode=False, logger=No end_len = struct.calcsize(end_fmt) headret_len = header_len + retcode_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 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') + 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] + retcode = ( + 0 + if no_retcode + else struct.unpack(MESSAGE_RETCODE_FMT, data[header_len:headret_len])[0] + ) # 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] + 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() + 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 + 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) + 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)) + logger.debug( + "HMAC checksum wrong! %r != %r", + binascii.hexlify(have_crc), + binascii.hexlify(crc), + ) else: - logger.debug('CRC wrong! %08X != %08X', have_crc, crc) + logger.debug("CRC wrong! %08X != %08X", have_crc, crc) + + return TuyaMessage( + header.seqno, header.cmd, retcode, payload[:-end_len], crc, crc == have_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') + 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)) + # 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) + 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) @@ -341,7 +382,8 @@ def __init__(self, key): def encrypt(self, raw, use_base64=True, pad=True): """Encrypt data to be sent to device.""" encryptor = self.cipher.encryptor() - if pad: raw = self._pad(raw) + if pad: + raw = self._pad(raw) crypted_text = encryptor.update(raw) + encryptor.finalize() return base64.b64encode(crypted_text) if use_base64 else crypted_text @@ -373,13 +415,13 @@ class MessageDispatcher(ContextualLogger): RESET_SEQNO = -101 SESS_KEY_SEQNO = -102 - def __init__(self, dev_id, listener, version, local_key): + def __init__(self, dev_id, listener, protocol_version, local_key): """Initialize a new MessageBuffer.""" super().__init__() self.buffer = b"" self.listeners = {} self.listener = listener - self.version = version + self.version = protocol_version self.local_key = local_key self.set_logger(_LOGGER, dev_id) @@ -420,7 +462,9 @@ def add_data(self, data): 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); + msg = unpack_message( + self.buffer, header=header, hmac_key=hmac_key, logger=self + ) self.buffer = self.buffer[header_len - 4 + header.length :] self._dispatch(msg) @@ -514,7 +558,6 @@ def __init__(self, dev_id, local_key, protocol_version, on_connected, listener): self.id = dev_id self.local_key = local_key.encode("latin1") self.real_local_key = self.local_key - self.version = protocol_version self.dev_type = "type_0a" self.dps_to_request = {} self.cipher = AESCipher(self.local_key) @@ -525,6 +568,8 @@ def __init__(self, dev_id, local_key, protocol_version, on_connected, listener): 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"" if protocol_version: self.set_version(float(protocol_version)) @@ -533,26 +578,27 @@ def __init__(self, dev_id, local_key, protocol_version, on_connected, listener): # them (such as BulbDevice) make connections when called TuyaProtocol.set_version(self, 3.1) - def set_version(self, version): - self.version = version - self.version_bytes = str(version).encode('latin1') + 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 version == 3.2: # 3.2 behaves like 3.3 with type_0d - #self.version = 3.3 - self.dev_type="type_0d" - if self.dps_to_request == {}: - self.detect_available_dps() - elif version == 3.4: + if protocol_version == 3.2: # 3.2 behaves like 3.3 with type_0d + # self.version = 3.3 + self.dev_type = "type_0d" + if self.dps_to_request == {}: + self.detect_available_dps() + elif protocol_version == 3.4: self.dev_type = "v3.4" elif self.dev_type == "v3.4": self.dev_type = "default" def error_json(self, number=None, payload=None): - """Return error details in JSON""" + """Return error details in JSON.""" try: spayload = json.dumps(payload) # spayload = payload.replace('\"','').replace('\'','') - except: + except Exception: spayload = '""' vals = (error_codes[number], str(number), spayload) @@ -640,43 +686,51 @@ async def close(self): self.transport = None transport.close() - # similar to exchange() but never retries sending and does not decode the response 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) + self.debug( + "[" + self.id + "] send quick failed, could not get socket: %s", payload + ) return None - enc_payload = self._encode_message(payload) if type(payload) == MessagePayload else payload + 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: + except Exception: # self._check_socket_close(True) self.close() return None while recv_retries: try: - #msg = await self._receive() seqno = MessageDispatcher.SESS_KEY_SEQNO - # seqno = self.seqno - 1 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: + 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) + 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) + 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() @@ -701,7 +755,7 @@ async def exchange(self, command, dps=None): enc_payload = self._encode_message(payload) self.transport.write(enc_payload) - msg = await self.dispatcher.wait_for(seqno, payload.cmd ) + msg = await self.dispatcher.wait_for(seqno, payload.cmd) if msg is None: self.debug("Wait was aborted for seqno %d", seqno) return None @@ -822,7 +876,7 @@ def _decode_payload(self, payload): try: # self.debug("decrypting=%r", payload) payload = cipher.decrypt(payload, False, decode_text=False) - except: + except Exception: self.debug("incomplete payload=%r (len:%d)", payload, len(payload)) return self.error_json(ERR_PAYLOAD) @@ -835,9 +889,9 @@ def _decode_payload(self, payload): # 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 + 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 ): + 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: @@ -848,7 +902,7 @@ def _decode_payload(self, payload): try: # self.debug("decrypting=%r", payload) payload = cipher.decrypt(payload, False) - except: + except Exception: self.debug("incomplete payload=%r (len:%d)", payload, len(payload)) return self.error_json(ERR_PAYLOAD) @@ -858,7 +912,7 @@ def _decode_payload(self, payload): if not isinstance(payload, str): try: payload = payload.decode() - except: + except Exception: self.debug("payload was not string type and decoding failed") return self.error_json(ERR_JSON, payload) if "data unvalid" in payload: @@ -877,28 +931,34 @@ def _decode_payload(self, payload): self.debug("Deciphered data = %r", payload) try: json_payload = json.loads(payload) - except: + except Exception: json_payload = self.error_json(ERR_JSON, payload) # 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'] + 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_nonce = b'0123456789abcdef' # not-so-random random key - self.remote_nonce = b'' 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 type(rkey) != TuyaMessage or len(rkey.payload) < 48: + 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) + self.debug( + "session key negotiation step 2 returned wrong command: %d", rkey.cmd + ) return False payload = rkey.payload @@ -906,8 +966,12 @@ async def _negotiate_session_key(self): # 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: - self.debug("session key step 2 decrypt failed, payload=%r (len:%d)", payload, len(payload)) + 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) @@ -920,23 +984,31 @@ async def _negotiate_session_key(self): 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 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 ) + 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.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.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 ): + def _encode_message(self, msg): hmac_key = None payload = msg.payload self.cipher = AESCipher(self.local_key) @@ -945,7 +1017,7 @@ def _encode_message( self, msg ): 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) + 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 @@ -977,7 +1049,7 @@ def _encode_message( self, msg ): 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) + buffer = pack_message(msg, hmac_key=hmac_key) # self.debug("payload encrypted with key %r => %r", self.local_key, binascii.hexlify(buffer)) return buffer @@ -997,16 +1069,26 @@ def _generate_payload(self, command, data=None, gwId=None, devId=None, uid=None) 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" 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 @@ -1014,7 +1096,6 @@ def _generate_payload(self, command, data=None, gwId=None, devId=None, uid=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": ""} - cmd_data = "" if "gwId" in json_data: if gwId is not None: @@ -1032,7 +1113,7 @@ def _generate_payload(self, command, data=None, gwId=None, devId=None, uid=None) else: json_data["uid"] = self.id if "t" in json_data: - if json_data['t'] == "int": + if json_data["t"] == "int": json_data["t"] = int(time.time()) else: json_data["t"] = str(int(time.time())) @@ -1057,7 +1138,6 @@ def _generate_payload(self, command, data=None, gwId=None, devId=None, uid=None) return MessagePayload(command_override, payload) - def __repr__(self): """Return internal string representation of object.""" return self.id diff --git a/custom_components/localtuya/select.py b/custom_components/localtuya/select.py index f643e081b..c9b1d1c62 100644 --- a/custom_components/localtuya/select.py +++ b/custom_components/localtuya/select.py @@ -4,19 +4,15 @@ 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 bc664bf5c..3776836e1 100644 --- a/custom_components/localtuya/switch.py +++ b/custom_components/localtuya/switch.py @@ -9,14 +9,14 @@ 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/pylint.rc b/pylint.rc index 4ec670e99..223e8810b 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 562dc776b..c4dd99f36 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 From 33c92dc28ccbf622a9f6f8c1f16a674c2f5a4f3a Mon Sep 17 00:00:00 2001 From: rospogrigio Date: Mon, 9 Jan 2023 23:49:22 +0100 Subject: [PATCH 10/14] Updated README.md and info.md --- README.md | 6 +++--- info.md | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a14e3a19d..26d9f274d 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. @@ -177,8 +177,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 +185,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. Buy Me A Coffee diff --git a/info.md b/info.md index c34669168..1527c8d13 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. @@ -177,16 +177,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. Buy Me A Coffee From cdcfd1d08cab72b5c42fec6c4560cb3aa0ebd578 Mon Sep 17 00:00:00 2001 From: rospogrigio Date: Mon, 9 Jan 2023 23:55:37 +0100 Subject: [PATCH 11/14] Fixed tox issues --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index f965d9f35..2602e5e1f 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 = From d8acf6007694671f563ac0cc39ddb30ee4cbff2c Mon Sep 17 00:00:00 2001 From: rospogrigio Date: Tue, 10 Jan 2023 11:31:37 +0100 Subject: [PATCH 12/14] Fix for version not being set yet when calling _setup_dispatcher --- .../localtuya/pytuya/__init__.py | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/custom_components/localtuya/pytuya/__init__.py b/custom_components/localtuya/pytuya/__init__.py index 55a97e11e..e6dbe27d8 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -560,6 +560,14 @@ def __init__(self, dev_id, local_key, protocol_version, on_connected, listener): 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 = 1 self.transport = None @@ -571,13 +579,6 @@ def __init__(self, dev_id, local_key, protocol_version, on_connected, listener): self.local_nonce = b"0123456789abcdef" # not-so-random random key self.remote_nonce = b"" - 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) - def set_version(self, protocol_version): """Set the device version and eventually start available DPs detection.""" self.version = protocol_version @@ -586,12 +587,8 @@ def set_version(self, protocol_version): if protocol_version == 3.2: # 3.2 behaves like 3.3 with type_0d # self.version = 3.3 self.dev_type = "type_0d" - if self.dps_to_request == {}: - self.detect_available_dps() elif protocol_version == 3.4: self.dev_type = "v3.4" - elif self.dev_type == "v3.4": - self.dev_type = "default" def error_json(self, number=None, payload=None): """Return error details in JSON.""" @@ -806,7 +803,7 @@ async def update_dps(self, dps=None): 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() From 33e0033b0a95003252779262e58a719a902c4d1e Mon Sep 17 00:00:00 2001 From: rospogrigio Date: Tue, 10 Jan 2023 18:18:35 +0100 Subject: [PATCH 13/14] Introduced the possibility to select which devices should have the pytuya debug enabled --- custom_components/localtuya/common.py | 2 + custom_components/localtuya/config_flow.py | 40 ++++++++----------- custom_components/localtuya/const.py | 1 + .../localtuya/pytuya/__init__.py | 26 ++++++++---- .../localtuya/translations/en.json | 1 + .../localtuya/translations/it.json | 1 + .../localtuya/translations/pt-BR.json | 1 + 7 files changed, 41 insertions(+), 31 deletions(-) diff --git a/custom_components/localtuya/common.py b/custom_components/localtuya/common.py index 76521eb94..85ed21c75 100644 --- a/custom_components/localtuya/common.py +++ b/custom_components/localtuya/common.py @@ -28,6 +28,7 @@ ATTR_STATE, ATTR_UPDATED_AT, CONF_DEFAULT_VALUE, + CONF_ENABLE_DEBUG, CONF_LOCAL_KEY, CONF_MODEL, CONF_PASSIVE_ENTITY, @@ -188,6 +189,7 @@ async def _make_connection(self): 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 0258a728a..8f37be92f 100644 --- a/custom_components/localtuya/config_flow.py +++ b/custom_components/localtuya/config_flow.py @@ -33,6 +33,7 @@ CONF_ADD_DEVICE, CONF_DPS_STRINGS, CONF_EDIT_DEVICE, + CONF_ENABLE_DEBUG, CONF_LOCAL_KEY, CONF_MANUAL_DPS, CONF_MODEL, @@ -82,30 +83,16 @@ } ) -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.2", "3.3", "3.4"] - ), - 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.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, @@ -145,15 +132,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_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.Optional(CONF_SCAN_INTERVAL): int, - vol.Optional(CONF_MANUAL_DPS): str, - vol.Optional(CONF_RESET_DPIDS): str, + 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), @@ -253,6 +241,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(",") @@ -570,6 +559,11 @@ async def async_step_configure_device(self, user_input=None): 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]) @@ -617,7 +611,7 @@ async def async_step_configure_device(self, user_input=None): 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 8010d18c6..3a6c25292 100644 --- a/custom_components/localtuya/const.py +++ b/custom_components/localtuya/const.py @@ -28,6 +28,7 @@ # 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/pytuya/__init__.py b/custom_components/localtuya/pytuya/__init__.py index e6dbe27d8..df34c9372 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -233,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): @@ -415,7 +419,7 @@ class MessageDispatcher(ContextualLogger): RESET_SEQNO = -101 SESS_KEY_SEQNO = -102 - def __init__(self, dev_id, listener, protocol_version, local_key): + def __init__(self, dev_id, listener, protocol_version, local_key, enable_debug): """Initialize a new MessageBuffer.""" super().__init__() self.buffer = b"" @@ -423,7 +427,7 @@ def __init__(self, dev_id, listener, protocol_version, local_key): self.listener = listener self.version = protocol_version self.local_key = local_key - self.set_logger(_LOGGER, dev_id) + self.set_logger(_LOGGER, dev_id, enable_debug) def abort(self): """Abort all waiting clients.""" @@ -540,7 +544,9 @@ def disconnected(self): 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. @@ -554,7 +560,7 @@ def __init__(self, dev_id, local_key, protocol_version, on_connected, listener): """ 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.real_local_key = self.local_key @@ -572,7 +578,7 @@ def __init__(self, dev_id, local_key, protocol_version, on_connected, listener): 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 = {} @@ -603,7 +609,7 @@ def error_json(self, number=None, payload=None): return json.loads('{ "Error":"%s", "Err":"%s", "Payload":%s }' % vals) - def _setup_dispatcher(self): + def _setup_dispatcher(self, enable_debug): def _status_update(msg): if msg.seqno > 0: self.seqno = msg.seqno + 1 @@ -615,7 +621,9 @@ def _status_update(msg): if listener is not None: listener.status_updated(self.dps_cache) - return MessageDispatcher(self.id, _status_update, self.version, self.local_key) + return MessageDispatcher( + self.id, _status_update, self.version, self.local_key, enable_debug + ) def connection_made(self, transport): """Did connect to the device.""" @@ -1145,6 +1153,7 @@ async def connect( device_id, local_key, protocol_version, + enable_debug, listener=None, port=6668, timeout=5, @@ -1157,6 +1166,7 @@ async def connect( device_id, local_key, protocol_version, + enable_debug, on_connected, listener or EmptyListener(), ), diff --git a/custom_components/localtuya/translations/en.json b/custom_components/localtuya/translations/en.json index 4b3ddb0ad..157fdf926 100644 --- a/custom_components/localtuya/translations/en.json +++ b/custom_components/localtuya/translations/en.json @@ -95,6 +95,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 faf4afa07..1cbd4c05e 100644 --- a/custom_components/localtuya/translations/it.json +++ b/custom_components/localtuya/translations/it.json @@ -95,6 +95,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 a2feed456..26f01acd9 100644 --- a/custom_components/localtuya/translations/pt-BR.json +++ b/custom_components/localtuya/translations/pt-BR.json @@ -95,6 +95,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)" } From bc8910549cd94a864bcec51b98a0c67184aa8056 Mon Sep 17 00:00:00 2001 From: rospogrigio Date: Tue, 10 Jan 2023 18:23:45 +0100 Subject: [PATCH 14/14] Introduced device debugging option in README.md and info.md --- README.md | 3 +++ info.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/README.md b/README.md index 26d9f274d..7f1c25f8d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/info.md b/info.md index 1527c8d13..eba32861b 100644 --- a/info.md +++ b/info.md @@ -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.