到目前为止我们已经有了比较完整的用户交互界面,在使用airdrop发送文件的时候,用户可以选择发送给哪一台设备,在这一章中,我们在PyQt5教程3 – 发送页面进度条,自定义信号槽和线程
的基础上在WiFi Drop上实现类似的功能,即当我们选中文件发送的时候,接下来软件需要能够搜索到局域网中其他的客户端。
1. 通讯模型
下图为WiFi Drop用到的通讯模型,绿色的线不建立Socket连接直接发送,而第一条初始化连接,寻找设备,发送局域网广播,该条消息会被局域网内所有其他装有WiFi Drop的机器接收,然后这些机器会将自己的机器名,ip,以及状态(ON)发送回给发起搜索的机器。然后,软件会将所有可用的机器列出来供用户选择,用户双击某台机器以后,软件会再次向这台机器发送确认申请,而目标机器的用户选择了同意接收以后,软件才会跟目标机器建立Socket连接(A机器作为客户端,B机器为服务器),同时通过Socket通讯将文件发送出去。
注意,在最终的软件中,机器A和机器B都是运行WiFI Drop软件,但是那需要我们做额外一些工作,在本章中,我们将使用一个简单的模拟小程序来模拟机器B,同时暂不实现确认发送的逻辑。
2. GUI显示
当搜索到局域网内的机器时,我们希望在发送界面的tableWidget中显示这些目标机器,每一行代表一台机器,并显示出其机器名,IP地址,当用户点击某一行的时候,表示用户想向该目标机器发送文件,如果目标机器同意接收,那么软件就可以将文件发送出去了。
我们利用Qt Creator将发送对话框上的tableWidget的属性修改一下以满足我们的要求:
- editTriggers:设置为NoEditTriggers,避免用户可以修改到显示出来的机器名,IP地址这些信息
- selectionMode:设置为SingleSelection,只允许选择一行,避免用户选择多台目标机器
- selectionBehavior:设置为SelectRows,当用户选择一台机器的时候,高亮对应的行
3. 代码实现
导入必要的库,这次新增了socket和QTableWidgetItem
1
2
3
|
import sys, os, socket
from PyQt5.QtWidgets import QApplication, QMainWindow, QLabel, QSpacerItem, QSizePolicy, QFileDialog, QDialog, QTableWidgetItem
|
3.1.监控socket信息
根据我们定义的通讯模型,软件要时刻监控socket上收到的信息,包括:
而做通讯监控要定期的去接收和判断所收到的消息是否格式正确,是会阻塞线程的。为了确保程序能正常的执行和响应,我们需要新建一个线程来专门做这一部分的工作,这里起名叫DeviceDiscoverThread,我们选择监控的端口为54545,在初始化中创建了socket对象,为了保证线程能够安全的退出,我们还设了running标志位,如果该标志位置为False以后,该socket对象会关闭,同时这个线程会停止并退出。
当该线程收到有任何一个其他机器发来的机器名和IP地址信息的时候,我们需要在发送对话框上显示出来,因此,我们需要通知发送对话框,所以我们在这个线程中添加了一个found_a_device信号,通过这个信号,可以将收到机器名,IP地址,以及状态发送出去。(我们将在稍后的代码中把这个信号连接到发送会话框的tabelWidget的更新上)
另外值得注意的是我们还设置了socket的超时时间,目的是保证recvfrom(1024)能够定时超时退出,保证running标志位可以被检测到,这样才能实现线程的安全退出。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
class DeviceDiscoverThread(QThread):
found_a_device = pyqtSignal(str, str, str)
def __init__(self):
super(DeviceDiscoverThread, self).__init__()
self.s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.s.settimeout(1)
self.s.bind(('',54545)) # port 54545 to receive broadcast message
self.running = True
def run(self):
while self.running:
m = None
address = None
try:
m, address = self.s.recvfrom(1024)
except socket.timeout:
pass
if m and address:
recv_str = m.decode("utf-8") # pythonlibrary 192.168.1.100 on
recv_items = recv_str.split()
if recv_items[0] == 'Init':
pass
else:
name = recv_items[1]
status = recv_items[2]
ip = address[0]
self.found_a_device.emit(name, ip, status)
self.s.close()
self.s = None
|
3.2.寻找设备
当用户选择完文件以后,软件会弹出发送对话框,寻找设备的动作也应该是在这个时候发生,因此我们在SendDialog中做一些改动:
- 将progressBar的进度设置成0
- 在tableWidget上建立两个列,一个显示计算机名,一个显示IP地址
- 将tableWidget的双击,绑定到send_out方法,用于一会我们实现发送文件给被选中的计算器
- 新建device_discover_thread,用来接收其他设备发回来的消息,并将其found_a_device信号绑定到update_devices方法,而后边我们将在update_devices方法中实现将收到的计算机名和IP地址显示在tableWidget中
- 启动device_discover_thread,及启动消息监控
- 新建socket_broadcast对象,其为一个socket对象,可以发送局域网定向或广播消息,并同时发送一条’Init’给局域网内所有机器的54545端口,用于查找其他WiFi Drop客户端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
class SendDialog(QDialog):
def __init__(self, url_list):
super(SendDialog, self).__init__()
# UI setup - 1 option
# dynamic load ui for development purpose
self.ui = loadUi('./dialog.ui', self)
self.ui.pushButtonClose.setStyleSheet("background-color:#ffffff;");
self.ui.pushButtonClose.setText("取消");
self.ui.pushButtonClose.clicked.connect(self.close)
self.ui.progressBar.setValue(0)
# set the properties in Qt Creator
# set editTriggers to NoEditTriggers
# set selectionMode to SingleSelection
# set selectionBehavior to SelectRows
for i in range(0,2):
self.ui.tableWidget.insertColumn(0)
self.ui.tableWidget.setHorizontalHeaderItem(0, QTableWidgetItem("计算机名"))
self.ui.tableWidget.setHorizontalHeaderItem(1, QTableWidgetItem("IP地址"))
self.ui.tableWidget.setColumnWidth(0, 160)
self.ui.tableWidget.setColumnWidth(1, 160)
self.ui.tableWidget.cellDoubleClicked.connect(self.send_out)
self.url_list = url_list
self.device_discover_thread = DeviceDiscoverThread()
self.device_discover_thread.found_a_device.connect(self.update_devices)
self.device_discover_thread.start()
self.socket_broadcast = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.socket_broadcast.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.socket_broadcast.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
self.socket_broadcast.sendto(b'Init', ('255.255.255.255', 54545)) # send broadcast message on 54545
|
前边已经将found_a_device绑定到update_devices方法,那么该方法可以实现如下,即使如果目标机器时On的状态,就将收到的计算机名和IP添加到表格中
1
2
3
4
5
6
7
|
@pyqtSlot(str,str,str)
def update_devices(self, name, ip, status):
if status == 'ServerOn':
rowPosition = self.ui.tableWidget.rowCount()
self.ui.tableWidget.insertRow(rowPosition)
self.ui.tableWidget.setItem(rowPosition , 0, QTableWidgetItem(name))
self.ui.tableWidget.setItem(rowPosition , 1, QTableWidgetItem(ip))
|
3.4.线程安全退出
当用户关闭发送对话框以后,我们希望通讯监控线程能安全退出,因此在SendDialog的closeEvent中添加
1
2
|
def closeEvent(self, event):
self.device_discover_thread.running = False
|
3.5.启动发送
当用户双击某个目标机器以后,我们将向该机器发起发送,因此实现绑定到双击信号的方法send_out如下,即获取到当前选择的机器名和ip地址,并将该ip地址传递给发送线程socket_client_thread,我们上一章中实现的socket_client_thread只有模拟发送,因此不接受ip地址参数,所以,在这里我们新增了一个ip地址参数,来实现实际发送
1
2
3
4
5
6
7
8
|
def send_out(self, row, col):
name = self.ui.tableWidget.item(row, 0).text()
ip = self.ui.tableWidget.item(row, 1).text()
self.socket_client_thread = SocketClientThread()
self.socket_client_thread.pass_para(self.url_list, ip)
self.socket_client_thread.progress_updated.connect(self.update_progress)
self.socket_client_thread.start()
|
3.6.发送文件
利用socket发送文件的相关代码代替我们之前实用的延时模拟代码,就可以将文件发送出去
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
class SocketClientThread(QThread):
progress_updated = pyqtSignal(int)
def __init__(self):
super(SocketClientThread, self).__init__()
self.running = False
self.progressbar = None
def pass_para(self, url_list, ip):
self.url_list = url_list
self.ip = ip
def run(self):
need_to_send = list()
# find files
for url in self.url_list:
if os.path.isfile(url):
need_to_send.append(url)
else:
for f in get_files_in_folder(url):
need_to_send.append(f)
# setup socket client for sending the files
s = socket.socket()
port = 12345
s.connect((self.ip, port))
for i, fname in enumerate(need_to_send):
progress = (100.0*i)/len(need_to_send)
# sending file with socket
filename = os.path.basename(fname)
size = len(filename)
size = bin(size)[2:].zfill(16) # encode filename size as 16 bit binary
s.send(size.encode())
s.send(filename.encode())
filename = fname
filesize = os.path.getsize(filename)
filesize = bin(filesize)[2:].zfill(32) # encode filesize as 32 bit binary
s.send(filesize.encode())
file_to_send = open(filename, 'rb')
l = file_to_send.read()
s.sendall(l)
file_to_send.close()
print('File Sent ' + filename)
# s.shutdown(socket.SHUT_WR)
self.progress_updated.emit(progress)
self.progress_updated.emit(100)
s.close()
|
4.联动测试
本章节最终实现的源代码可以在git仓库 https://github.com/pythonlibrary/wifidrop
中的** tutorial-4** tag的commit位置找到 ,其中在test文件夹下包含了一个dummy_client.py脚本文件,用来模拟机器B,需要将它放到局域网内的另外一个台机器上运行,我使用了一个台树莓派进行模拟,最终效果为:
系列文章传送门: