Skip to content

Commit 49cd721

Browse files
authored
Merge pull request #612 from uzlonewolf/broadcast-relay
Tool updates: pcap parse fix, new broadcast relay
2 parents 3fa3870 + b9e4146 commit 49cd721

File tree

3 files changed

+234
-9
lines changed

3 files changed

+234
-9
lines changed

tinytuya/scanner.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,8 @@ def send_discovery_request( iface_list=None ):
252252
log.error( line )
253253
log.error( 'Sending broadcast discovery packet failed, certain v3.5 devices will not be found!' )
254254

255+
return iface_list
256+
255257
class KeyObj(object):
256258
def __init__( self, gwId, key ):
257259
self.gwId = gwId

tools/broadcast-relay.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
#!/usr/bin/env python
2+
# -*- coding: utf-8 -*-
3+
# PYTHON_ARGCOMPLETE_OK
4+
5+
"""
6+
A program that listens for broadcast packets from Tuya devices and sends them via unicast to App clients.
7+
Useful to make the app work on broadcast-blocking WiFi networks.
8+
9+
Written by uzlonewolf (https://github.com/uzlonewolf) for the TinyTuya project https://github.com/jasonacox/tinytuya
10+
11+
Call with "-h" for options.
12+
"""
13+
14+
BROADCASTTIME = 6 # How often to broadcast to port 7000 to get v3.5 devices to send us their info
15+
16+
import json
17+
import logging
18+
import socket
19+
import select
20+
import time
21+
import traceback
22+
import argparse
23+
24+
from tinytuya import decrypt_udp, UDPPORT, UDPPORTS, UDPPORTAPP
25+
from tinytuya.scanner import send_discovery_request
26+
27+
try:
28+
import argcomplete
29+
HAVE_ARGCOMPLETE = True
30+
except:
31+
HAVE_ARGCOMPLETE = False
32+
33+
if __name__ == '__main__':
34+
log = logging.getLogger( 'broadcast-relay' )
35+
else:
36+
log = logging.getLogger(__name__)
37+
38+
def relay( args ):
39+
log.info( 'Starting Relay' )
40+
41+
send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
42+
#send_sock.bind(("", 0))
43+
44+
# Enable UDP listening broadcasting mode on UDP port 6666 - 3.1 Devices
45+
client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
46+
client.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
47+
try:
48+
client.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
49+
except AttributeError:
50+
# SO_REUSEPORT not available
51+
pass
52+
client.bind(("", UDPPORT))
53+
54+
# Enable UDP listening broadcasting mode on encrypted UDP port 6667 - 3.2-3.5 Devices
55+
clients = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
56+
clients.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
57+
try:
58+
clients.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
59+
except AttributeError:
60+
# SO_REUSEPORT not available
61+
pass
62+
clients.bind(("", UDPPORTS))
63+
64+
# Enable UDP listening broadcasting mode on encrypted UDP port 7000 - App
65+
clientapp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
66+
clientapp.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
67+
try:
68+
clientapp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
69+
except AttributeError:
70+
# SO_REUSEPORT not available
71+
pass
72+
clientapp.bind(("", UDPPORTAPP))
73+
74+
broadcasted_apps = {}
75+
our_broadcasts = {}
76+
read_socks = []
77+
write_socks = []
78+
broadcast_query_timer = 0
79+
80+
while True:
81+
read_socks = [client, clients, clientapp]
82+
write_socks = []
83+
84+
try:
85+
rd, wr, _ = select.select( read_socks, write_socks, [] )
86+
except KeyboardInterrupt as err:
87+
log.warning("**User Break**")
88+
break
89+
90+
for sock in rd:
91+
data, addr = sock.recvfrom(4048)
92+
ip = addr[0]
93+
result = b''
94+
95+
if sock is clientapp:
96+
tgt_port = UDPPORTAPP
97+
result = None
98+
99+
if ip in our_broadcasts:
100+
log.debug( 'Ignoring our own broadcast: %r', ip )
101+
continue
102+
103+
try:
104+
result = decrypt_udp( data )
105+
result = json.loads(result)
106+
except:
107+
log.warning( '* Invalid UDP Packet from %r port %r: %r (%r)', ip, tgt_port, result, data, exc_info=True )
108+
continue
109+
110+
if 'from' in result and result['from'] == 'app':
111+
client_ip = result['ip'] if 'ip' in result else ip
112+
113+
if client_ip not in broadcasted_apps:
114+
log.info( 'New Broadcast from App at %r (%r) - %r', client_ip, ip, result )
115+
else:
116+
log.debug( 'Updated Broadcast from App at %r (%r) - %r', client_ip, ip, result )
117+
118+
broadcasted_apps[client_ip] = time.time() + (2 * BROADCASTTIME)
119+
120+
if broadcast_query_timer < time.time():
121+
broadcast_query_timer = time.time() + BROADCASTTIME
122+
our_broadcasts = send_broadcast()
123+
continue
124+
elif 'gwId' in result:
125+
# queried v3.5 device response, let it fall through
126+
pass
127+
else:
128+
log.warning( 'New Broadcast from App does not contain app data! src:%r - %r', ip, result )
129+
continue
130+
131+
elif sock is client:
132+
tgt_port = UDPPORT
133+
elif sock is clients:
134+
tgt_port = UDPPORTS
135+
else:
136+
tgt_port = '???'
137+
log.warning( 'Sock not known??' )
138+
139+
#log.debug("UDP Packet from %r port %r", ip, tgt_port)
140+
141+
#if 'gwId' not in result:
142+
# print("* Payload missing required 'gwId' - from %r to port %r: %r (%r)\n" % (ip, tgt_port, result, data))
143+
# log.debug("UDP Packet payload missing required 'gwId' - from %r port %r - %r", ip, tgt_port, data)
144+
# continue
145+
146+
need_delete = []
147+
for client_ip in broadcasted_apps:
148+
if broadcasted_apps[client_ip] < time.time():
149+
need_delete.append( client_ip )
150+
continue
151+
152+
dst = (client_ip, tgt_port)
153+
#log.debug( 'Sending to: %r', dst )
154+
send_sock.sendto( data, dst )
155+
156+
for client_ip in need_delete:
157+
log.info( 'Client App aged out: %r', client_ip )
158+
del broadcasted_apps[client_ip]
159+
160+
client.close()
161+
clients.close()
162+
clientapp.close()
163+
164+
return
165+
166+
def send_broadcast():
167+
"""
168+
Send broadcasts to query for newer v3.5 devices
169+
"""
170+
our_broadcasts = send_discovery_request()
171+
if not our_broadcasts:
172+
our_broadcasts = {}
173+
return our_broadcasts
174+
175+
if __name__ == '__main__':
176+
disc = 'Listens for broadcast packets from Tuya devices and sends them via unicast to App clients. Useful to make the app work on broadcast-blocking WiFi networks.'
177+
epi = None #'The "-s" option is designed to make the output display packets in the correct order when sorted, i.e. with `python3 pcap_parse.py ... | sort`'
178+
arg_parser = argparse.ArgumentParser( description=disc, epilog=epi )
179+
arg_parser.add_argument( '-debug', '-d', help='Enable debug messages', action='store_true' )
180+
181+
if HAVE_ARGCOMPLETE:
182+
argcomplete.autocomplete( arg_parser )
183+
184+
args = arg_parser.parse_args()
185+
186+
logging.basicConfig( level=logging.DEBUG + (0 if args.debug else 1) )
187+
188+
relay( args )

tools/pcap_parse.py

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -240,10 +240,10 @@ def get_key( dev=None, mac=None, ip=None ):
240240

241241
# first lookup by device id, if provided
242242
if dev:
243-
for dev in devices:
244-
if 'id' in dev and 'key' in dev and dev['key']:
245-
if dev == dev['id']:
246-
return dev['id'], dev['key'], ver
243+
for check_dev in devices:
244+
if 'id' in check_dev and 'key' in check_dev and check_dev['key']:
245+
if dev == check_dev['id']:
246+
return check_dev['id'], check_dev['key'], ver
247247

248248
# if no device id, try the mac
249249
if mac:
@@ -280,13 +280,30 @@ def process_pcap( pcap_file, args ):
280280
continue
281281

282282
if( isinstance(eth.ip.data, dpkt.udp.UDP) ):
283-
if( (eth.ip.udp.dport == 6667 or eth.ip.udp.dport == 6666 or eth.ip.udp.dport == 7000) and eth.ip.src not in ip_devs ):
283+
if( eth.ip.udp.dport == 6667 or eth.ip.udp.dport == 6666 or eth.ip.udp.dport == 7000 ):
284+
if( (eth.ip.dst == b'\xff\xff\xff\xff') or (eth.ip.udp.dport == 7000) ):
285+
devip = inet_to_str( eth.ip.src )
286+
if devip in ip_devs:
287+
continue
288+
devmac = mac_to_str( eth.src )
289+
else:
290+
devip = None
291+
devmac = None
292+
284293
try:
285294
data = eth.ip.udp.data
286-
devmac = mac_to_str( eth.src )
287-
devip = inet_to_str( eth.ip.src )
288295
payload_raw = tinytuya.decrypt_udp( data )
289296
payload = json.loads( payload_raw )
297+
298+
if not devip:
299+
if payload and 'ip' in payload:
300+
devip = payload['ip']
301+
if devip in ip_devs:
302+
continue
303+
304+
if not devip:
305+
print( 'No IP for device?? ', eth.ip.src, ':', eth.ip.udp.sport, '->', eth.ip.dst, ':', eth.ip.udp.dport, 'sent:', payload )
306+
290307
bcast_dev = devip + ':' + str(eth.ip.udp.dport)
291308
if bcast_dev not in bcast_devs:
292309
if 'gwId' not in payload:
@@ -299,6 +316,7 @@ def process_pcap( pcap_file, args ):
299316
ip_devs[devip] = payload
300317
except:
301318
traceback.print_exc()
319+
continue
302320

303321
if( isinstance(eth.ip.data, dpkt.tcp.TCP) ):
304322
data = None
@@ -387,14 +405,31 @@ def process_pcap( pcap_file, args ):
387405
arg_parser = argparse.ArgumentParser( description=disc, epilog=epi )
388406
arg_parser.add_argument( '-z', '--hide-zero-len', help='Hide 0-length heartbeat packets', action='store_true' )
389407
arg_parser.add_argument( '-s', '--sortable', help='Output data in a way which is sortable by device ID', action='store_true' )
390-
arg_parser.add_argument( '-d', '--devices', help='devices.json file to read local keys from', default='devices.json', metavar='devices.json', type=argparse.FileType('rb'), required=True )
408+
arg_parser.add_argument( '-d', '--devices', help='devices.json file to read local keys from', default=None, metavar='devices.json' ) #, type=argparse.FileType('rb') )
391409
arg_parser.add_argument( 'files', metavar='INFILE.pcap', nargs='+', help='Input file(s) to parse', type=argparse.FileType('rb') )
392410

393411
if HAVE_ARGCOMPLETE:
394412
argcomplete.autocomplete( arg_parser )
395413

396414
args = arg_parser.parse_args()
397-
devices = json.load( args.devices )
415+
416+
if not args.devices:
417+
try:
418+
with open( 'devices.json', 'rb' ) as fp:
419+
devices = json.load( fp )
420+
if not args.sortable:
421+
print( 'Using \'devices.json\' for device keys' )
422+
except Exception as e:
423+
try:
424+
with open( '../devices.json', 'rb' ) as fp:
425+
devices = json.load( fp )
426+
if not args.sortable:
427+
print( 'Using \'../devices.json\' for device keys' )
428+
except:
429+
raise e
430+
else:
431+
with open( args.devices, 'rb' ) as fp:
432+
devices = json.load( fp )
398433

399434
args.fnum = 0
400435
args.ftot = len(args.files)

0 commit comments

Comments
 (0)