Skip to content

Commit 99d8ac8

Browse files
committed
Add auto-find IP Address and fix #17
1 parent fa6dcf6 commit 99d8ac8

2 files changed

Lines changed: 112 additions & 11 deletions

File tree

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ Classes
7474
BulbDevice(dev_id, address, local_key=None, dev_type='default')
7575
7676
dev_id (str): Device ID e.g. 01234567891234567890
77-
address (str): Device Network IP Address e.g. 10.0.1.99
77+
address (str): Device Network IP Address e.g. 10.0.1.99 or 0.0.0.0 to auto-find
7878
local_key (str, optional): The encryption key. Defaults to None.
7979
dev_type (str): Device type for payload options (see below)
8080
@@ -88,9 +88,10 @@ Classes
8888
set_retry(retry=True) # retry if response payload is truncated
8989
set_status(on, switch=1) # Set status of the device to 'on' or 'off' (bool)
9090
set_value(index, value) # Set int value of any index.
91-
turn_on(switch=1):
92-
turn_off(switch=1):
93-
set_timer(num_secs):
91+
turn_on(switch=1)
92+
turn_off(switch=1)
93+
set_timer(num_secs)
94+
heartbeat() # Send Tuya Heartbeat
9495
9596
CoverDevice:
9697
open_cover(switch=1):

tinytuya/__init__.py

Lines changed: 107 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@
8686
Crypto = AES = None
8787
import pyaes # https://github.com/ricmoo/pyaes
8888

89-
version_tuple = (1, 1, 2)
89+
version_tuple = (1, 1, 3)
9090
version = __version__ = '%d.%d.%d' % version_tuple
9191
__author__ = 'jasonacox'
9292

@@ -227,7 +227,7 @@ def hex2bin(x):
227227
},
228228
HEART_BEAT: {
229229
"hexByte": "09",
230-
"command": {}
230+
"command": {"gwId": "", "devId": ""}
231231
},
232232
DP_QUERY: { # Get Data Points from Device
233233
"hexByte": "0a",
@@ -261,7 +261,7 @@ def hex2bin(x):
261261
},
262262
HEART_BEAT: {
263263
"hexByte": "09",
264-
"command": {}
264+
"command": {"gwId": "", "devId": ""}
265265
},
266266
"prefix": "000055aa00000000000000",
267267
"suffix": "000000000000aa55"
@@ -296,10 +296,19 @@ def __init__(self, dev_id, address, local_key="", dev_type="default", connection
296296
self.socketPersistent = False
297297
self.socketNODELAY = True
298298
self.socketRetryLimit = 5
299+
if(address == None or address == 'Auto' or address == '0.0.0.0'):
300+
# try to determine IP address automatically
301+
(addr, ver) = self.find(dev_id)
302+
if(addr == None):
303+
raise Exception('Unable to find device on network (specify IP address)')
304+
self.address = addr
305+
if(ver == "3.3"):
306+
self.version = 3.3
299307

300308
def __del__(self):
301309
# In case we have a lingering socket connection, close it
302310
if self.socket != None:
311+
# self.socket.shutdown(socket.SHUT_RDWR)
303312
self.socket.close()
304313
self.socket = None
305314

@@ -309,6 +318,7 @@ def __repr__(self):
309318

310319
def _get_socket(self, renew):
311320
if(renew and self.socket != None):
321+
# self.socket.shutdown(socket.SHUT_RDWR)
312322
self.socket.close()
313323
self.socket = None
314324
if(self.socket == None):
@@ -335,8 +345,10 @@ def _send_receive(self, payload):
335345
self.socket.send(payload)
336346
data = self.socket.recv(1024)
337347
# Some devices fail to send full payload in first response
338-
# Note - some devices respond with len = 28 for error response
339-
if self.retry and len(data) < 28:
348+
# At minimum requires: prefix (4), sequence (4), command (4), length (4),
349+
# CRC (4), and suffix (4) for 24 total bytes
350+
# Messages from the device also include return code (4), for 28 total bytes
351+
if self.retry and len(data) <= 28:
340352
time.sleep(0.1)
341353
data = self.socket.recv(1024) # try again
342354
success = True
@@ -362,6 +374,7 @@ def _send_receive(self, payload):
362374
self._get_socket(True)
363375
# except
364376
# while
377+
# signal we are done reading
365378
return data
366379

367380
def set_version(self, version):
@@ -381,6 +394,80 @@ def set_dpsUsed(self, dpsUsed):
381394

382395
def set_retry(self, retry):
383396
self.retry = retry
397+
398+
def find(self, did=None):
399+
"""Scans network for Tuya devices with ID = did
400+
401+
Parameters:
402+
did = The specific Device ID you are looking for (returns only IP and Version)
403+
404+
Response:
405+
(ip, version)
406+
"""
407+
if(did == None):
408+
return(None, None)
409+
# Enable UDP listening broadcasting mode on UDP port 6666 - 3.1 Devices
410+
client = socket.socket(
411+
socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
412+
client.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
413+
client.bind(("", UDPPORT))
414+
client.settimeout(TIMEOUT)
415+
# Enable UDP listening broadcasting mode on encrypted UDP port 6667 - 3.3 Devices
416+
clients = socket.socket(
417+
socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
418+
clients.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
419+
clients.bind(("", UDPPORTS))
420+
clients.settimeout(TIMEOUT)
421+
422+
count = 0
423+
counts = 0
424+
maxretry = 30
425+
ret = (None, None)
426+
427+
while (count + counts) <= maxretry:
428+
if (count <= counts): # alternate between 6666 and 6667 ports
429+
try:
430+
data, addr = client.recvfrom(4048)
431+
count = count + 1
432+
except:
433+
# Timeout
434+
count = count + 1
435+
continue
436+
else:
437+
try:
438+
data, addr = clients.recvfrom(4048)
439+
counts = counts + 1
440+
except:
441+
# Timeout
442+
counts = counts + 1
443+
continue
444+
ip = addr[0]
445+
gwId = version = ""
446+
result = data
447+
try:
448+
result = data[20:-8]
449+
try:
450+
result = decrypt_udp(result)
451+
except:
452+
result = result.decode()
453+
454+
result = json.loads(result)
455+
ip = result['ip']
456+
gwId = result['gwId']
457+
version = result['version']
458+
except:
459+
result = {"ip": ip}
460+
461+
# Check to see if we are only looking for one device
462+
if(gwId == did):
463+
# We found it!
464+
ret = (ip, version)
465+
break
466+
467+
# while
468+
clients.close()
469+
client.close()
470+
return(ret)
384471

385472
def generate_payload(self, command, data=None):
386473
"""
@@ -438,7 +525,7 @@ def generate_payload(self, command, data=None):
438525
# some tuya libraries strip 8: to :24
439526
json_payload = PROTOCOL_VERSION_BYTES_31 + \
440527
hexdigest[8:][:16].encode('latin1') + json_payload
441-
self.cipher = None # expect to connect and then disconnect to set new
528+
self.cipher = None
442529

443530
postfix_payload = hex2bin(
444531
bin2hex(json_payload) + payload_dict[self.dev_type]['suffix'])
@@ -484,7 +571,7 @@ def status(self):
484571
# got an encrypted payload, happens occasionally
485572
# expect resulting json to look similar to:: {"devId":"ID","dps":{"1":true,"2":0},"t":EPOCH_SECS,"s":3_DIGIT_NUM}
486573
# NOTE dps.2 may or may not be present
487-
result = result[len(PROTOCOL_VERSION_BYTES_31) :] # remove version header
574+
result = result[len(PROTOCOL_VERSION_BYTES_31):] # remove version header
488575
# Remove 16-bytes appears to be MD5 hexdigest of payload
489576
result = result[16:]
490577
cipher = AESCipher(self.local_key)
@@ -523,6 +610,17 @@ def set_status(self, on, switch=1):
523610

524611
return data
525612

613+
def heartbeat(self):
614+
"""
615+
Send a simple HEART_BEAT command to device.
616+
617+
"""
618+
# open device, send request, then close connection
619+
payload = self.generate_payload(HEART_BEAT)
620+
data = self._send_receive(payload)
621+
log.debug('heartbeat received data=%r', data)
622+
return data
623+
526624
def set_value(self, index, value):
527625
"""
528626
Set int value of any index.
@@ -1274,6 +1372,8 @@ def tuyaLookup(deviceid):
12741372
print(" \n%sScan Complete! Found %s devices.\n" %
12751373
(normal, len(devices)))
12761374

1375+
clients.close()
1376+
client.close()
12771377
return(devices)
12781378

12791379

0 commit comments

Comments
 (0)