diff --git a/README.md b/README.md index 2fafcee8..651dfba9 100644 --- a/README.md +++ b/README.md @@ -137,13 +137,23 @@ Global Functions set_debug(toggle, color) # Activate verbose debugging output Classes - OutletDevice(dev_id, address, local_key=None, dev_type='default') - CoverDevice(dev_id, address, local_key=None, dev_type='default') - BulbDevice(dev_id, address, local_key=None, dev_type='default') + OutletDevice(args...) + CoverDevice(args...) + BulbDevice(args...) + Where args: dev_id (str): Device ID e.g. 01234567891234567890 - address (str): Device Network IP Address e.g. 10.0.1.99 or 0.0.0.0 to auto-find - local_key (str, optional): The encryption key. Defaults to None. + address (str): Device Network IP Address e.g. 10.0.1.99 or "Auto" to auto-find + local_key (str): The encryption key dev_type (str): Device type for payload options (see below) + connection_timeout = 5 (int): Timeout in seconds + version = 3.1 (float): Tuya Protocol (e.g. 3.1, 3.2, 3.3, 3.4, 3.5) + persist = False (bool): Keep TCP link open + cid = None (str): Optional sub device id + node_id = None (str): Alias for cid + parent = None (object): Gateway device object this is a child of + connection_retry_limit = 5 (int) + connection_retry_delay = 5 (int) + Cloud(apiRegion, apiKey, apiSecret, apiDeviceID, new_sign_algorithm) @@ -243,6 +253,7 @@ The "Err" number will be one of these: * 911 (ERR_CLOUDTOKEN) - Unable to Get Cloud Token * 912 (ERR_PARAMS) - Missing Function Parameters * 913 (ERR_CLOUD) - Error Response from Tuya Cloud +* 914 (ERR_KEY_OR_VER) - Check device key or version ### Example Usage diff --git a/RELEASE.md b/RELEASE.md index 6012454e..49b9dd8b 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,11 @@ # RELEASE NOTES +## v1.12.10 - Various Updates + +* PyPI 1.12.10 +* Updates to scanner, added error code and helpful troubleshooting messages, make connection/key errors more descriptive. +* Added socketRetryLimit (`connection_retry_limit`) and socketRetryDelay (`connection_retry_limit`) to Device constructor args. + ## v1.12.9 - Import Issue with urllib3 * PyPI 1.12.9 diff --git a/examples/monitor.py b/examples/monitor.py index 906d2111..7d79a094 100644 --- a/examples/monitor.py +++ b/examples/monitor.py @@ -8,41 +8,59 @@ """ import tinytuya +import time # tinytuya.set_debug(True) -d = tinytuya.OutletDevice('DEVICEID', 'DEVICEIP', 'DEVICEKEY') -d.set_version(3.3) -d.set_socketPersistent(True) +# Setting the address to 'Auto' or None will trigger a scan which will auto-detect both the address and version, but this can take up to 8 seconds +d = tinytuya.OutletDevice('DEVICEID', 'Auto', 'DEVICEKEY', persist=True) +# If you know both the address and version then supplying them is a lot quicker +# d = tinytuya.OutletDevice('DEVICEID', 'DEVICEIP', 'DEVICEKEY', version=DEVICEVERSION, persist=True) + +STATUS_TIMER = 30 +KEEPALIVE_TIMER = 12 print(" > Send Request for Status < ") -payload = d.generate_payload(tinytuya.DP_QUERY) -d.send(payload) +data = d.status() +print('Initial Status: %r' % data) +if data and 'Err' in data: + print("Status request returned an error, is version %r and local key %r correct?" % (d.version, d.local_key)) print(" > Begin Monitor Loop <") +heartbeat_time = time.time() + KEEPALIVE_TIMER +status_time = None + +# Uncomment if you want the monitor to constantly request status - otherwise you +# will only get updates when state changes +#status_time = time.time() + STATUS_TIMER + while(True): - # See if any data is available - data = d.receive() + if status_time and time.time() >= status_time: + # Uncomment if your device provides power monitoring data but it is not updating + # Some devices require a UPDATEDPS command to force measurements of power. + # print(" > Send DPS Update Request < ") + # Most devices send power data on DPS indexes 18, 19 and 20 + # d.updatedps(['18','19','20'], nowait=True) + # Some Tuya devices will not accept the DPS index values for UPDATEDPS - try: + # payload = d.generate_payload(tinytuya.UPDATEDPS) + # d.send(payload) + + # poll for status + print(" > Send Request for Status < ") + data = d.status() + status_time = time.time() + STATUS_TIMER + heartbeat_time = time.time() + KEEPALIVE_TIMER + elif time.time() >= heartbeat_time: + # send a keep-alive + data = d.heartbeat(nowait=False) + heartbeat_time = time.time() + KEEPALIVE_TIMER + else: + # no need to send anything, just listen for an asynchronous update + data = d.receive() + print('Received Payload: %r' % data) - # Send keyalive heartbeat - print(" > Send Heartbeat Ping < ") - payload = d.generate_payload(tinytuya.HEART_BEAT) - d.send(payload) - - # Uncomment if you want the monitor to constantly request status - otherwise you - # will only get updates when state changes - # print(" > Send Request for Status < ") - # payload = d.generate_payload(tinytuya.DP_QUERY) - # d.send(payload) - - # Uncomment if your device provides power monitoring data but it is not updating - # Some devices require a UPDATEDPS command to force measurements of power. - # print(" > Send DPS Update Request < ") - # Most devices send power data on DPS indexes 18, 19 and 20 - # payload = d.generate_payload(tinytuya.UPDATEDPS,['18','19','20']) - # Some Tuya devices will not accept the DPS index values for UPDATEDPS - try: - # payload = d.generate_payload(tinytuya.UPDATEDPS) - # d.send(payload) - - \ No newline at end of file + if data and 'Err' in data: + print("Received error!") + # rate limit retries so we don't hammer the device + time.sleep(5) diff --git a/server/server.py b/server/server.py index 456d54e5..ca797d3e 100644 --- a/server/server.py +++ b/server/server.py @@ -274,6 +274,11 @@ def tuyalisten(port): # Enable UDP listening broadcasting mode on UDP port client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) client.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + try: + client.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + except AttributeError: + # SO_REUSEPORT not available + pass client.bind(("", port)) client.settimeout(5) diff --git a/tinytuya/core.py b/tinytuya/core.py index 9c8f30d9..3d1cfb7a 100644 --- a/tinytuya/core.py +++ b/tinytuya/core.py @@ -11,7 +11,9 @@ Classes * AESCipher - Cryptography Helpers * XenonDevice(...) - Base Tuya Objects and Functions - XenonDevice(dev_id, address=None, local_key="", dev_type="default", connection_timeout=5, version="3.1", persist=False, cid/node_id=None, parent=None) + XenonDevice(dev_id, address=None, local_key="", dev_type="default", connection_timeout=5, + version="3.1", persist=False, cid/node_id=None, parent=None, connection_retry_limit=5, + connection_retry_delay=5) * Device(XenonDevice) - Tuya Class for Devices Module Functions @@ -97,7 +99,7 @@ # Colorama terminal color capability for all platforms init() -version_tuple = (1, 12, 9) +version_tuple = (1, 12, 10) version = __version__ = "%d.%d.%d" % version_tuple __author__ = "jasonacox" @@ -199,6 +201,7 @@ ERR_CLOUDTOKEN = 911 ERR_PARAMS = 912 ERR_CLOUD = 913 +ERR_KEY_OR_VER = 914 error_codes = { ERR_JSON: "Invalid JSON Response from Device", @@ -215,6 +218,7 @@ ERR_CLOUDTOKEN: "Unable to Get Cloud Token", ERR_PARAMS: "Missing Function Parameters", ERR_CLOUD: "Error Response from Tuya Cloud", + ERR_KEY_OR_VER: "Check device key or version", None: "Unknown Error", } @@ -528,14 +532,26 @@ def find_device(dev_id=None, address=None): if dev_id is None and address is None: return (None, None, None) log.debug("Listening for device %s on the network", dev_id) + # Enable UDP listening broadcasting mode on UDP port 6666 - 3.1 Devices client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) client.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + try: + client.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + except AttributeError: + # SO_REUSEPORT not available + pass client.bind(("", UDPPORT)) client.setblocking(False) + # Enable UDP listening broadcasting mode on encrypted UDP port 6667 - 3.3 Devices clients = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) clients.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + try: + clients.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + except AttributeError: + # SO_REUSEPORT not available + pass clients.bind(("", UDPPORTS)) clients.setblocking(False) @@ -755,7 +771,7 @@ def assign_dp_mappings( tuyadevices, mappings ): class XenonDevice(object): def __init__( - self, dev_id, address=None, local_key="", dev_type="default", connection_timeout=5, version=3.1, persist=False, cid=None, node_id=None, parent=None # pylint: disable=W0621 + self, dev_id, address=None, local_key="", dev_type="default", connection_timeout=5, version=3.1, persist=False, cid=None, node_id=None, parent=None, connection_retry_limit=5, connection_retry_delay=5 # pylint: disable=W0621 ): """ Represents a Tuya device. @@ -775,6 +791,7 @@ def __init__( self.id = dev_id self.cid = cid if cid else node_id self.address = address + self.auto_ip = False self.dev_type = dev_type self.dev_type_auto = self.dev_type == 'default' self.last_dev_type = '' @@ -785,8 +802,8 @@ def __init__( self.socket = None self.socketPersistent = False if not persist else True # pylint: disable=R1719 self.socketNODELAY = True - self.socketRetryLimit = 5 - self.socketRetryDelay = 5 + self.socketRetryLimit = connection_retry_limit + self.socketRetryDelay = connection_retry_delay self.version = 0 self.dps_to_request = {} self.seqno = 1 @@ -824,6 +841,7 @@ def __init__( self.parent._register_child(self) elif (not address) or address == "Auto" or address == "0.0.0.0": # try to determine IP address automatically + self.auto_ip = True bcast_data = find_device(dev_id) if bcast_data['ip'] is None: log.debug("Unable to find device on network (specify IP address)") @@ -865,7 +883,28 @@ def _get_socket(self, renew): if self.socket is None: # Set up Socket retries = 0 + err = ERR_OFFLINE while retries < self.socketRetryLimit: + if self.auto_ip and not self.address: + bcast_data = find_device(self.id) + if bcast_data['ip'] is None: + log.debug("Unable to find device on network (specify IP address)") + return ERR_OFFLINE + self.address = bcast_data['ip'] + new_version = float(bcast_data['version']) + if new_version != self.version: + # this may trigger a network call which will call _get_socket() again + #self.set_version(new_version) + self.version = new_version + self.version_str = "v" + str(version) + self.version_bytes = str(version).encode('latin1') + self.version_header = self.version_bytes + PROTOCOL_3x_HEADER + self.payload_dict = None + + if not self.address: + log.debug("No address for device!") + return ERR_OFFLINE + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) if self.socketNODELAY: self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) @@ -877,6 +916,11 @@ def _get_socket(self, renew): # restart session key negotiation if self._negotiate_session_key(): return True + else: + if self.socket: + self.socket.close() + self.socket = None + return ERR_KEY_OR_VER else: return True except socket.timeout as err: @@ -885,19 +929,23 @@ def _get_socket(self, renew): "socket unable to connect (timeout) - retry %d/%d", retries, self.socketRetryLimit ) + err = ERR_OFFLINE except Exception as err: # unable to open socket log.debug( "socket unable to connect (exception) - retry %d/%d", retries, self.socketRetryLimit, exc_info=True ) + err = ERR_CONNECT if self.socket: self.socket.close() self.socket = None if retries < self.socketRetryLimit: time.sleep(self.socketRetryDelay) + if self.auto_ip: + self.address = None # unable to get connection - return False + return err # existing socket active return True @@ -918,7 +966,8 @@ def _recv_all(self, length): tries -= 1 if tries == 0: raise DecodeError('No data received - connection closed?') - time.sleep(0.1) + if self.sendWait is not None: + time.sleep(self.sendWait) continue data += newdata length -= len(newdata) @@ -972,7 +1021,7 @@ def _send_receive_quick(self, payload, recv_retries, from_child=None): # pylint: return self.parent._send_receive_quick(payload, recv_retries, from_child=self) log.debug("sending payload quick") - if not self._get_socket(False): + if self._get_socket(False) is not True: return None enc_payload = self._encode_message(payload) if type(payload) == MessagePayload else payload try: @@ -1033,17 +1082,19 @@ def _send_receive(self, payload, minresponse=28, getresponse=True, decode_respon msg = None while not success: # open up socket if device is available - if not self._get_socket(False): + sock_result = self._get_socket(False) + if sock_result is not True: # unable to get a socket - device likely offline self._check_socket_close(True) - return error_json(ERR_OFFLINE) + return error_json( sock_result if sock_result else ERR_OFFLINE ) # send request to device try: if payload is not None and do_send: log.debug("sending payload") enc_payload = self._encode_message(payload) if type(payload) == MessagePayload else payload self.socket.sendall(enc_payload) - time.sleep(self.sendWait) # give device time to respond + if self.sendWait is not None: + time.sleep(self.sendWait) # give device time to respond if getresponse: do_send = False rmsg = self._receive() @@ -1061,7 +1112,7 @@ def _send_receive(self, payload, minresponse=28, getresponse=True, decode_respon else: success = True log.debug("received message=%r", msg) - if not getresponse: + else: # legacy/default mode avoids persisting socket across commands self._check_socket_close() return None @@ -1076,6 +1127,7 @@ def _send_receive(self, payload, minresponse=28, getresponse=True, decode_respon return None do_send = True retries += 1 + # toss old socket and get new one self._check_socket_close(True) log.debug( "Timeout in _send_receive() - retry %s / %s", @@ -1088,13 +1140,9 @@ def _send_receive(self, payload, minresponse=28, getresponse=True, decode_respon self.socketRetryLimit ) # timeout reached - return error - json_payload = error_json( - ERR_TIMEOUT, "Check device key or version" - ) - return json_payload - # retry: wait a bit, toss old socket and get new one + return error_json(ERR_KEY_OR_VER) + # wait a bit before retrying time.sleep(0.1) - self._get_socket(True) except DecodeError as err: log.debug("Error decoding received data - read retry %s/%s", recv_retries, max_recv_retries, exc_info=True) recv_retries += 1 @@ -1110,6 +1158,7 @@ def _send_receive(self, payload, minresponse=28, getresponse=True, decode_respon # likely network or connection error do_send = True retries += 1 + # toss old socket and get new one self._check_socket_close(True) log.debug( "Network connection error in _send_receive() - retry %s/%s", @@ -1123,11 +1172,9 @@ def _send_receive(self, payload, minresponse=28, getresponse=True, decode_respon ) log.debug("Unable to connect to device ") # timeout reached - return error - json_payload = error_json(ERR_CONNECT) - return json_payload - # retry: wait a bit, toss old socket and get new one + return error_json(ERR_CONNECT) + # wait a bit before retrying time.sleep(0.1) - self._get_socket(True) # except # while @@ -1464,6 +1511,8 @@ def status(self, nowait=False): log.debug("status() rebuilding payload for device22") payload = self.generate_payload(query_type) data = self._send_receive(payload) + elif data["Err"] == str(ERR_PAYLOAD): + log.debug("Status request returned an error, is version %r and local key %r correct?", self.version, self.local_key) return data @@ -1745,9 +1794,11 @@ def product(self): log.debug("product received data=%r", data) return data - def heartbeat(self, nowait=False): + def heartbeat(self, nowait=True): """ - Send a simple HEART_BEAT command to device. + Send a keep-alive HEART_BEAT command to keep the TCP connection open. + + Devices only send an empty-payload response, so no need to wait for it. Args: nowait(bool): True to send without waiting for response. diff --git a/tinytuya/scanner.py b/tinytuya/scanner.py index ae9b92ea..1e750e1c 100644 --- a/tinytuya/scanner.py +++ b/tinytuya/scanner.py @@ -1025,15 +1025,33 @@ def tuyaLookup(deviceid): # Enable UDP listening broadcasting mode on UDP port 6666 - 3.1 Devices client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) client.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + try: + client.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + except AttributeError: + # SO_REUSEPORT not available + pass client.bind(("", UDPPORT)) #client.settimeout(TIMEOUT) + # Enable UDP listening broadcasting mode on encrypted UDP port 6667 - 3.3 Devices clients = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) clients.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + try: + clients.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + except AttributeError: + # SO_REUSEPORT not available + pass clients.bind(("", UDPPORTS)) #clients.settimeout(TIMEOUT) + + # Enable UDP listening broadcasting mode on encrypted UDP port 7000 - App clientapp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) clientapp.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + try: + clientapp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + except AttributeError: + # SO_REUSEPORT not available + pass clientapp.bind(("", UDPPORTAPP)) else: client = clients = clientapp = None