From 957cf25dd0af995c496a5644c7a773b46a426b04 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 | 713 ++++++++++++++---- 1 file changed, 546 insertions(+), 167 deletions(-) diff --git a/custom_components/localtuya/pytuya/__init__.py b/custom_components/localtuya/pytuya/__init__.py index d36cd5e..a417534 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -3,11 +3,8 @@ """ Python module to interface with Tuya WiFi smart devices. -Mostly derived from Shenzhen Xenon ESP8266MOD WiFi smart devices -E.g. https://wikidevi.com/wiki/Xenon_SM-PW701U - -Author: clach04 -Maintained by: postlund +Author: clach04, postlund +Maintained by: rospogrigio For more information see https://github.com/clach04/python-tuya @@ -19,7 +16,7 @@ Classes Functions json = status() # returns json payload - set_version(version) # 3.1 [default] or 3.3 + set_version(version) # 3.1 [default], 3.2, 3.3 or 3.4 detect_available_dps() # returns a list of available dps provided by the device update_dps(dps) # sends update dps command add_dps_to_request(dp_index) # adds dp_index to the list of dps used by the @@ -27,18 +24,21 @@ Functions set_dp(on, dp_index) # Set value of any dps index. -Credits - * TuyaAPI https://github.com/codetheweb/tuyapi by codetheweb and blackrozes - For protocol reverse engineering - * PyTuya https://github.com/clach04/python-tuya by clach04 - The origin of this python module (now abandoned) - * LocalTuya https://github.com/rospogrigio/localtuya-homeassistant by rospogrigio - Updated pytuya to support devices with Device IDs of 22 characters + Credits + * TuyaAPI https://github.com/codetheweb/tuyapi by codetheweb and blackrozes + For protocol reverse engineering + * PyTuya https://github.com/clach04/python-tuya by clach04 + The origin of this python module (now abandoned) + * Tuya Protocol 3.4 Support by uzlonewolf + Enhancement to TuyaMessage logic for multi-payload messages and Tuya Protocol 3.4 support + * TinyTuya https://github.com/jasonacox/tinytuya by jasonacox + Several CLI tools and code for Tuya devices """ import asyncio import base64 import binascii +import hmac import json import logging import struct @@ -46,18 +46,58 @@ import time import weakref from abc import ABC, abstractmethod from collections import namedtuple -from hashlib import md5 +from hashlib import md5,sha256 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes -version_tuple = (9, 0, 0) +version_tuple = (10, 0, 0) version = version_string = __version__ = "%d.%d.%d" % version_tuple -__author__ = "postlund" +__author__ = "rospogrigio" _LOGGER = logging.getLogger(__name__) -TuyaMessage = namedtuple("TuyaMessage", "seqno cmd retcode payload crc") +# Tuya Packet Format +TuyaHeader = namedtuple('TuyaHeader', 'prefix seqno cmd length') +MessagePayload = namedtuple("MessagePayload", "cmd payload") +try: + TuyaMessage = namedtuple("TuyaMessage", "seqno cmd retcode payload crc crc_good", defaults=(True,)) +except: + 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 @@ 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 +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": { + DP_QUERY: { # Get Data Points from Device + "command_override": CONTROL_NEW, # Uses CONTROL_NEW command for some reason + "command": {"devId": "", "uid": "", "t": ""}, }, }, - "type_0d": { - STATUS: {"hexByte": 0x0D, "command": {"devId": "", "uid": "", "t": ""}}, - SET: {"hexByte": 0x07, "command": {"devId": "", "uid": "", "t": ""}}, - HEARTBEAT: {"hexByte": 0x09, "command": {}}, - UPDATEDPS: {"hexByte": 0x12, "command": {"dpId": [18, 19, 20]}}, - }, + "v3.4": { + CONTROL: { + "command_override": CONTROL_NEW, # Uses CONTROL_NEW command + "command": {"protocol":5, "t": "int", "data": ""} + }, + DP_QUERY: { "command_override": DP_QUERY_NEW }, + } } + + class TuyaLoggingAdapter(logging.LoggerAdapter): """Adapter that adds device id to all log points.""" @@ -158,8 +249,9 @@ class ContextualLogger: return self._logger.exception(msg, *args) -def pack_message(msg): +def pack_message(msg,hmac_key=None): """Pack a TuyaMessage into bytes.""" + end_fmt = MESSAGE_END_FMT_HMAC if hmac_key else MESSAGE_END_FMT # Create full message excluding CRC and suffix buffer = ( struct.pack( @@ -167,28 +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 - _, seqno, cmd, _, retcode = struct.unpack( - MESSAGE_RECV_HEADER_FMT, data[:header_len] + if len(data) < headret_len+end_len: + logger.debug('unpack_message(): not enough data to unpack header! need %d but only have %d', headret_len+end_len, len(data)) + raise DecodeError('Not enough data to unpack header') + + if header is None: + header = parse_header(data) + + if len(data) < header_len+header.length: + logger.debug('unpack_message(): not enough data to unpack payload! need %d but only have %d', header_len+header.length, len(data)) + raise DecodeError('Not enough data to unpack payload') + + retcode = 0 if no_retcode else struct.unpack(MESSAGE_RETCODE_FMT, data[header_len:headret_len])[0] + # the retcode is technically part of the payload, but strip it as we do not want it here + payload = data[header_len+retcode_len:header_len+header.length] + crc, suffix = struct.unpack(end_fmt, payload[-end_len:]) + + if hmac_key: + have_crc = hmac.new(hmac_key, data[:(header_len+header.length)-end_len], sha256).digest() + else: + have_crc = binascii.crc32(data[:(header_len+header.length)-end_len]) & 0xFFFFFFFF + + if suffix != SUFFIX_VALUE: + logger.debug('Suffix prefix wrong! %08X != %08X', suffix, SUFFIX_VALUE) + + if crc != have_crc: + if hmac_key: + logger.debug('HMAC checksum wrong! %r != %r', binascii.hexlify(have_crc), binascii.hexlify(crc)) + else: + logger.debug('CRC wrong! %08X != %08X', have_crc, crc) + + return TuyaMessage(header.seqno, header.cmd, retcode, payload[:-end_len], crc, crc == have_crc) + +def parse_header(data): + 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 @@ class AESCipher: self.block_size = 16 self.cipher = Cipher(algorithms.AES(key), modes.ECB(), default_backend()) - def encrypt(self, raw, use_base64=True): + def encrypt(self, raw, use_base64=True, pad=True): """Encrypt data to be sent to device.""" encryptor = self.cipher.encryptor() - crypted_text = encryptor.update(self._pad(raw)) + encryptor.finalize() + if pad: raw = self._pad(raw) + crypted_text = encryptor.update(raw) + encryptor.finalize() return base64.b64encode(crypted_text) if use_base64 else crypted_text - def decrypt(self, enc, use_base64=True): + def decrypt(self, enc, use_base64=True, decode_text=True): """Decrypt data from device.""" if use_base64: enc = base64.b64decode(enc) decryptor = self.cipher.decryptor() - return self._unpad(decryptor.update(enc) + decryptor.finalize()).decode() + raw = self._unpad(decryptor.update(enc) + decryptor.finalize()) + return raw.decode("utf-8") if decode_text else raw def _pad(self, data): padnum = self.block_size - len(data) % self.block_size @@ -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 @@ class MessageDispatcher(ContextualLogger): if isinstance(sem, asyncio.Semaphore): sem.release() - async def wait_for(self, seqno, timeout=5): + async def wait_for(self, seqno, cmd, timeout=5): """Wait for response to a sequence number to be received and return it.""" if seqno in self.listeners: raise Exception(f"listener exists for {seqno}") - self.debug("Waiting for sequence number %d", seqno) + self.debug("Command %d waiting for sequence number %d", cmd, seqno) self.listeners[seqno] = asyncio.Semaphore(0) try: await asyncio.wait_for(self.listeners[seqno].acquire(), timeout=timeout) @@ -273,51 +423,39 @@ class MessageDispatcher(ContextualLogger): if len(self.buffer) < header_len: break - # Parse header and check if enough data according to length in header - _, seqno, cmd, length, retcode = struct.unpack_from( - MESSAGE_RECV_HEADER_FMT, self.buffer - ) - 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 @@ class MessageDispatcher(ContextualLogger): self.debug("Got status update") self.listener(msg) else: - self.debug( - "Got message type %d for unknown listener %d: %s", - msg.cmd, - msg.seqno, - msg, - ) + if msg.cmd == CONTROL_NEW: + self.debug("Got ACK message for command %d: will ignore it", msg.cmd) + else: + self.error( + "Got message type %d for unknown listener %d: %s", + msg.cmd, + msg.seqno, + msg, + ) class TuyaListener(ABC): @@ -377,11 +518,12 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): if listener is not None: listener.status_updated(self.dps_cache) - return MessageDispatcher(self.id, _status_update) + return MessageDispatcher(self.id, _status_update, self.version, self.local_key) def connection_made(self, transport): """Did connect to the device.""" @@ -434,11 +610,13 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): def data_received(self, data): """Received data from device.""" + # self.debug("received data=%r", binascii.hexlify(data)) self.dispatcher.add_data(data) def connection_lost(self, exc): """Disconnected from device.""" self.debug("Connection lost: %s", exc) + self.real_local_key = self.local_key try: listener = self.listener and self.listener() if listener is not None: @@ -449,6 +627,7 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): async def close(self): """Close connection and abort all outstanding listeners.""" self.debug("Closing connection") + self.real_local_key = self.local_key if self.heartbeater is not None: self.heartbeater.cancel() try: @@ -464,31 +643,78 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): async def status(self): """Return device status.""" - status = await self.exchange(STATUS) + status = await self.exchange(DP_QUERY) if status and "dps" in status: self.dps_cache.update(status["dps"]) return self.dps_cache @@ -539,7 +765,8 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): dps = list(set(dps).intersection(set(UPDATE_DPS_WHITELIST))) self.debug("updatedps() entry (dps %s, dps_cache %s)", dps, self.dps_cache) payload = self._generate_payload(UPDATEDPS, dps) - self.transport.write(payload) + enc_payload = self._encode_message(payload) + self.transport.write(enc_payload) return True async def set_dp(self, value, dp_index): @@ -550,11 +777,11 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): dp_index(int): dps index to set value: new value for the dps index """ - return await self.exchange(SET, {str(dp_index): value}) + return await self.exchange(CONTROL, {str(dp_index): value}) async def set_dps(self, dps): """Set values for a set of datapoints.""" - return await self.exchange(SET, dps) + return await self.exchange(CONTROL, dps) async def detect_available_dps(self): """Return which datapoints are supported by the device.""" @@ -591,38 +818,175 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): self.dps_to_request.update({str(index): None for index in dp_indicies}) def _decode_payload(self, payload): - if not payload: - payload = "{}" - elif payload.startswith(b"{"): - pass - elif payload.startswith(PROTOCOL_VERSION_BYTES_31): - payload = payload[len(PROTOCOL_VERSION_BYTES_31) :] # remove version header - # remove (what I'm guessing, but not confirmed is) 16-bytes of MD5 - # hexdigest of payload - payload = self.cipher.decrypt(payload[16:]) - elif self.version == 3.3: - if self.dev_type != "type_0a" or payload.startswith( - PROTOCOL_VERSION_BYTES_33 - ): - payload = payload[len(PROTOCOL_33_HEADER) :] - payload = self.cipher.decrypt(payload, False) + cipher = AESCipher(self.local_key) + if self.version == 3.4: + # 3.4 devices encrypt the version header in addition to the payload + try: + # self.debug("decrypting=%r", payload) + payload = cipher.decrypt(payload, False, decode_text=False) + except: + 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) - def _generate_payload(self, command, data=None): + # v3.4 stuffs it into {"data":{"dps":{"1":true}}, ...} + if "dps" not in json_payload and "data" in json_payload and "dps" in json_payload['data']: + json_payload['dps'] = json_payload['data']['dps'] + + return json_payload + + async def _negotiate_session_key(self): + self.local_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 + ) + + 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): This is one of the entries from payload_dict data(dict, optional): The data to be send. This is what will be passed via the 'dps' entry + gwId(str, optional): Will be used for gwId + devId(str, optional): Will be used for devId + uid(str, optional): Will be used for uid """ - cmd_data = PAYLOAD_DICT[self.dev_type][command] - json_data = cmd_data["command"] - command_hb = cmd_data["hexByte"] + json_data = command_override = None + + if command in payload_dict[self.dev_type]: + if 'command' in payload_dict[self.dev_type][command]: + json_data = payload_dict[self.dev_type][command]['command'] + if 'command_override' in payload_dict[self.dev_type][command]: + command_override = payload_dict[self.dev_type][command]['command_override'] + + if self.dev_type != 'type_0a': + if json_data is None and command in payload_dict['type_0a'] and 'command' in payload_dict['type_0a'][command]: + json_data = payload_dict['type_0a'][command]['command'] + if command_override is None and command in payload_dict['type_0a'] and 'command_override' in payload_dict['type_0a'][command]: + command_override = payload_dict['type_0a'][command]['command_override'] + + if command_override is None: + command_override = command + if json_data is None: + # I have yet to see a device complain about included but unneeded attribs, but they *will* + # complain about missing attribs, so just include them all unless otherwise specified + json_data = {"gwId": "", "devId": "", "uid": "", "t": ""} + 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 c6a9460d06477e999e5f1428d5f1d5f8a09b7282 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 1eeb3b5..cea8314 100644 --- a/custom_components/localtuya/config_flow.py +++ b/custom_components/localtuya/config_flow.py @@ -88,7 +88,7 @@ CONFIGURE_DEVICE_SCHEMA = vol.Schema( 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 @@ DEVICE_SCHEMA = vol.Schema( 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 0fb619960c31fe58f3c0a372d051252bc4070e44 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 a417534..06fc170 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -99,12 +99,6 @@ error_codes = { 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): # 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 a3914cc8ce5d270939eb45d5832eb56ac9e8494e 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 06fc170..1241e02 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -1052,7 +1052,7 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 960f07b6bbf2bec27025ecc6de07825e5ef3927a 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 1241e02..32dc744 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -704,7 +704,7 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 edc6752ecfc3a4b808a6da5fa7133bd2dd46b5ee 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 32dc744..3f24d50 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -366,8 +366,9 @@ class AESCipher: class MessageDispatcher(ContextualLogger): """Buffer and dispatcher for Tuya messages.""" - # Heartbeats always respond with sequence number 0, so they can't be waited for like - # other messages. This is a hack to allow waiting for heartbeats. + # Heartbeats on protocols < 3.3 respond with sequence number 0, + # so they can't be waited for like other messages. + # This is a hack to allow waiting for heartbeats. HEARTBEAT_SEQNO = -100 RESET_SEQNO = -101 SESS_KEY_SEQNO = -102 @@ -418,7 +419,7 @@ class MessageDispatcher(ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 c9d6bc521ea12ab15b5843fcb75169be2e89ae38 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 3f24d50..259ed57 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -562,7 +562,7 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 3bf69d69f00ca315f33fe42dd2c374780b364ed8 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 | 314 +++++++++++------- pylint.rc | 10 +- setup.cfg | 4 +- 4 files changed, 214 insertions(+), 126 deletions(-) diff --git a/custom_components/localtuya/config_flow.py b/custom_components/localtuya/config_flow.py index cea8314..761a241 100644 --- a/custom_components/localtuya/config_flow.py +++ b/custom_components/localtuya/config_flow.py @@ -88,7 +88,9 @@ CONFIGURE_DEVICE_SCHEMA = vol.Schema( 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 @@ DEVICE_SCHEMA = vol.Schema( 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 259ed57..55a97e1 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -46,7 +46,7 @@ import time 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 @@ __author__ = "rospogrigio" _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 @@ error_codes = { 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_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 ] +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 @@ payload_dict = { "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 @@ class ContextualLogger: 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 @@ class AESCipher: 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 @@ class MessageDispatcher(ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): # 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): # 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): # 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 "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 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): # 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 4ec670e..223e881 100644 --- a/pylint.rc +++ b/pylint.rc @@ -171,10 +171,12 @@ disable=line-too-long, deprecated-sys-function, exception-escape, comprehension-escape, - unused-variable, - invalid-name, - dangerous-default-value, - unreachable + unused-variable, + invalid-name, + dangerous-default-value, + unreachable, + unnecessary-pass, + broad-except # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/setup.cfg b/setup.cfg index 562dc77..c4dd99f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,10 +1,10 @@ [flake8] exclude = .git,.tox -max-line-length = 88 +max-line-length = 120 ignore = E203, W503 [mypy] -python_version = 3.7 +python_version = 3.9 ignore_errors = true follow_imports = silent ignore_missing_imports = true From 39ef76bafb29da52d2b803c943369ffdacb0586f 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 | 2 +- custom_components/localtuya/fan.py | 2 +- custom_components/localtuya/number.py | 7 +++---- custom_components/localtuya/select.py | 10 +++------- custom_components/localtuya/switch.py | 6 +++--- 6 files changed, 16 insertions(+), 21 deletions(-) diff --git a/custom_components/localtuya/common.py b/custom_components/localtuya/common.py index aa8992f..76521eb 100644 --- a/custom_components/localtuya/common.py +++ b/custom_components/localtuya/common.py @@ -25,18 +25,18 @@ from homeassistant.helpers.restore_state import RestoreEntity 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 761a241..0258a72 100644 --- a/custom_components/localtuya/config_flow.py +++ b/custom_components/localtuya/config_flow.py @@ -34,6 +34,7 @@ from .const import ( CONF_DPS_STRINGS, CONF_EDIT_DEVICE, CONF_LOCAL_KEY, + CONF_MANUAL_DPS, CONF_MODEL, CONF_NO_CLOUD, CONF_PRODUCT_NAME, @@ -45,7 +46,6 @@ from .const import ( DATA_DISCOVERY, DOMAIN, PLATFORMS, - CONF_MANUAL_DPS, ) from .discovery import discover diff --git a/custom_components/localtuya/fan.py b/custom_components/localtuya/fan.py index 584ea84..32c3289 100644 --- a/custom_components/localtuya/fan.py +++ b/custom_components/localtuya/fan.py @@ -27,12 +27,12 @@ from .const import ( CONF_FAN_DIRECTION, CONF_FAN_DIRECTION_FWD, CONF_FAN_DIRECTION_REV, + CONF_FAN_DPS_TYPE, CONF_FAN_ORDERED_LIST, CONF_FAN_OSCILLATING_CONTROL, CONF_FAN_SPEED_CONTROL, CONF_FAN_SPEED_MAX, CONF_FAN_SPEED_MIN, - CONF_FAN_DPS_TYPE, ) _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/localtuya/number.py b/custom_components/localtuya/number.py index 23d7ea9..917d3d0 100644 --- a/custom_components/localtuya/number.py +++ b/custom_components/localtuya/number.py @@ -7,14 +7,13 @@ from homeassistant.components.number import DOMAIN, NumberEntity from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN from .common import LocalTuyaEntity, async_setup_entry - from .const import ( - CONF_MIN_VALUE, - CONF_MAX_VALUE, CONF_DEFAULT_VALUE, + CONF_MAX_VALUE, + CONF_MIN_VALUE, + CONF_PASSIVE_ENTITY, CONF_RESTORE_ON_RECONNECT, CONF_STEPSIZE_VALUE, - CONF_PASSIVE_ENTITY, ) _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/localtuya/select.py b/custom_components/localtuya/select.py index f643e08..c9b1d1c 100644 --- a/custom_components/localtuya/select.py +++ b/custom_components/localtuya/select.py @@ -4,19 +4,15 @@ from functools import partial import voluptuous as vol from homeassistant.components.select import DOMAIN, SelectEntity -from homeassistant.const import ( - CONF_DEVICE_CLASS, - STATE_UNKNOWN, -) +from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN from .common import LocalTuyaEntity, async_setup_entry - from .const import ( + CONF_DEFAULT_VALUE, CONF_OPTIONS, CONF_OPTIONS_FRIENDLY, - CONF_DEFAULT_VALUE, - CONF_RESTORE_ON_RECONNECT, CONF_PASSIVE_ENTITY, + CONF_RESTORE_ON_RECONNECT, ) diff --git a/custom_components/localtuya/switch.py b/custom_components/localtuya/switch.py index bc664bf..3776836 100644 --- a/custom_components/localtuya/switch.py +++ b/custom_components/localtuya/switch.py @@ -9,14 +9,14 @@ from .common import LocalTuyaEntity, async_setup_entry from .const import ( ATTR_CURRENT, ATTR_CURRENT_CONSUMPTION, - ATTR_VOLTAGE, ATTR_STATE, + ATTR_VOLTAGE, CONF_CURRENT, CONF_CURRENT_CONSUMPTION, - CONF_VOLTAGE, CONF_DEFAULT_VALUE, - CONF_RESTORE_ON_RECONNECT, CONF_PASSIVE_ENTITY, + CONF_RESTORE_ON_RECONNECT, + CONF_VOLTAGE, ) _LOGGER = logging.getLogger(__name__) From 95b3113e88481c2588c5808c15635157f82ffdf2 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 a14e3a1..26d9f27 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 c346691..1527c8d 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 2f0f507cada6454234087b26334d70163db1107f 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 f965d9f..2602e5e 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ python = 3.9: clean, py39, lint, typing [testenv] -passenv = TOXENV CI +passenv = TOXENV,CI whitelist_externals = true setenv = From 0f3160f178e03e9038e1effa5f514805bad9b90f 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 55a97e1..e6dbe27 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -560,6 +560,14 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): 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 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): Args: dps([int]): list of dps to update, default=detected&whitelisted """ - if self.version == 3.3: + if self.version in [3.2, 3.3]: # 3.2 behaves like 3.3 with type_0d if dps is None: if not self.dps_cache: await self.detect_available_dps() From c379645ea125439a42590d61fd04793149e6dc16 Mon Sep 17 00:00:00 2001 From: rospogrigio Date: Tue, 10 Jan 2023 12:54:39 +0100 Subject: [PATCH 13/14] Introduced abort in config flow if all entities are deselected when editing a device --- custom_components/localtuya/config_flow.py | 5 +++++ custom_components/localtuya/translations/en.json | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/custom_components/localtuya/config_flow.py b/custom_components/localtuya/config_flow.py index 0258a72..f1e87bf 100644 --- a/custom_components/localtuya/config_flow.py +++ b/custom_components/localtuya/config_flow.py @@ -570,6 +570,11 @@ class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow): CONF_ENTITIES: [], } ) + if len(user_input[CONF_ENTITIES]) == 0: + return self.async_abort( + reason="no_entities", + description_placeholders={}, + ) if user_input[CONF_ENTITIES]: entity_ids = [ int(entity.split(":")[0]) diff --git a/custom_components/localtuya/translations/en.json b/custom_components/localtuya/translations/en.json index 4b3ddb0..3684986 100644 --- a/custom_components/localtuya/translations/en.json +++ b/custom_components/localtuya/translations/en.json @@ -33,7 +33,8 @@ "options": { "abort": { "already_configured": "Device has already been configured.", - "device_success": "Device {dev_name} successfully {action}." + "device_success": "Device {dev_name} successfully {action}.", + "no_entities": "Cannot remove all entities from a device.\nIf you want to delete a device, enter it in the Devices menu, click the 3 dots in the 'Device info' frame, and press the Delete button." }, "error": { "authentication_failed": "Failed to authenticate.\n{msg}", From 20ab4017c1ca88b31fb683cff590b4d1fcd9bb3e Mon Sep 17 00:00:00 2001 From: rospogrigio Date: Tue, 10 Jan 2023 18:27:26 +0100 Subject: [PATCH 14/14] Introduced IT and PT translations --- custom_components/localtuya/translations/it.json | 3 ++- custom_components/localtuya/translations/pt-BR.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/custom_components/localtuya/translations/it.json b/custom_components/localtuya/translations/it.json index faf4afa..4addeab 100644 --- a/custom_components/localtuya/translations/it.json +++ b/custom_components/localtuya/translations/it.json @@ -33,7 +33,8 @@ "options": { "abort": { "already_configured": "Il dispositivo è già stato configurato.", - "device_success": "Dispositivo {dev_name} {action} con successo." + "device_success": "Dispositivo {dev_name} {action} con successo.", + "no_entities": "Non si possono rimuovere tutte le entities da un device.\nPer rimuovere un device, entrarci nel menu Devices, premere sui 3 punti nel riquadro 'Device info', e premere il pulsante Delete." }, "error": { "authentication_failed": "Autenticazione fallita. Errore:\n{msg}", diff --git a/custom_components/localtuya/translations/pt-BR.json b/custom_components/localtuya/translations/pt-BR.json index a2feed4..4a3630c 100644 --- a/custom_components/localtuya/translations/pt-BR.json +++ b/custom_components/localtuya/translations/pt-BR.json @@ -33,7 +33,8 @@ "options": { "abort": { "already_configured": "O dispositivo já foi configurado.", - "device_success": "Dispositivo {dev_name} {action} com sucesso." + "device_success": "Dispositivo {dev_name} {action} com sucesso.", + "no_entities": "Não é possível remover todas as entidades de um dispositivo.\nSe você deseja excluir um dispositivo, insira-o no menu Dispositivos, clique nos 3 pontos no quadro 'Informações do dispositivo' e pressione o botão Excluir." }, "error": { "authentication_failed": "Falha ao autenticar.\n{msg}",