前边几篇文章中我们已经将发送和界面功能实现, 对于一个文件收发软件,它既可以是主机来发送文件,也可以作为目标机来接收文件,在这一篇文章中,我们将实现剩下所有的功能,即完成接收功能。
为了更直观的给大家展示本节的最终实现目标,我们先把实现结果的视频放在最前边,因为一台电脑不能给自己发送文件,因此,本节的测试需要使用两台电脑,并且这两天电脑需要连接到同一个局域网中。
如果大家对前边的文章有点印象不深,可以从下边传送门进入
0. 话不多少看效果
主机-发送
主机向目标机发送文件的时候,目标机会弹出一个选择窗口,用户可以选择接收或者拒绝,当目标机拒绝接收以后,主机会弹出窗口告诉用户说目标机拒绝了文件接收,文件当然也就不会被发送。而如果目标机选择确认接收文件,那么主机就会将这些文件发送过去。
目标机-接收
当主机向目标机发送文件的时候,目标机客户端会弹出一个窗口供用户选择是否同意接收文件,如果选择同意,那么则会接收并保存主机发过来的软件,而选择拒绝的话,主机就不会发送文件过来了。
1. 话不多说看实现
导入必要的库,这次新增了 QMessageBox ,目的是为了实现弹窗
1
|
from PyQt5.QtWidgets import QApplication, QMainWindow, QLabel, QSpacerItem, QSizePolicy, QFileDialog, QDialog, QTableWidgetItem, QMessageBox
|
1.1 监控Socket
回顾上一章内容,我们利用socket显示了文件的发送,那么作为接收端,我们也相应的需要一个接收线程来监控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
|
class SocketServerThread(QThread):
def __init__(self):
super(SocketServerThread, self).__init__()
self.running = True
self.connected = False
def run(self):
while True:
while self.running:
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
host = socket.gethostname()
port = 12345
s.settimeout(1)
s.bind(('', port))
s.listen(5)
try:
c, addr = s.accept()
self.connected = True
except socket.timeout:
self.connected = False
while self.running and self.connected:
size = c.recv(16) # Note that you limit your filename length to 255 bytes.
if not size:
break
size = int(size.decode(), 2)
filename = c.recv(size)
filesize = c.recv(32)
filesize = int(filesize.decode(), 2)
file_to_write = open(filename, 'wb')
chunksize = 4096
while filesize > 0:
if filesize < chunksize:
chunksize = filesize
data = c.recv(chunksize)
file_to_write.write(data)
filesize -= len(data)
file_to_write.close()
print('File received successfully')
s.close()
|
类似的,这个线程的主逻辑都是在run里边实现的,而self.running标志位的作用是控制线程和socket的状态,因为我们的软件是同样可以作为主机或者是目标机使用的,因此发送和接收的功能需要同时支持,而发送和接收使用到的socket是同一个端口,所以我们必须提供一个机制,让用户在想发送文件的时候停止掉接口监控,从而释放socket端口,同时如果用户退出发送以后,则接收线程应该返回正常的监控状态。这里self.running标志位就是为了实现这样的逻辑设计的,当用户需要发送文件的时候,这个标志位会为False,那么线程则暂时不会监控socket的接收,同样当文件发送完毕后,这个标志位会转为True,这样线程可以继续开始监控。
而当running为True的时候,监控逻辑就是一个简单的从socket收数据过来,然后保存到文件的逻辑。值得注意的是,我们的软件支持多个文件的发送/接收,在没发送一个文件的时候,软件会先将文件名,文件大小发送过来,方便在接收的时候区分不同文件的数据,和保存文件到正确的文件名。
有了接收线程类以后,我们在主窗口实例化的时候将其实例化,就实现了线程随着软件启动的功能了。
1
2
|
self.socket_server_thread = SocketServerThread()
self.socket_server_thread.start()
|
1.2 实现接收确认
要实现接收确认的功能,要求目标机和主机之间有来回的信息交流,我们在前一篇已经实现了基本的通讯模型,用于寻找局域网中的设备,在本篇中,我们只需要进行一些必须的修改,完善其功能就可以了。
首先,完善我们的信息交流socket协议逻辑,讲DeviceDiscoverThread线程类中,改成下边这样,改动主要是,添加了allow_sending 和device_discover_pack_received 两个信号:
-
前边一个,当软件为主机时,用来通知主程序是否发送文件(目标机确认接收则发送,拒绝接收则不发送),信号带有一个布尔参数,True代表接受,False代表拒绝
-
后边一个信号,用来通知主程序目前接收到的主机命令是什么
- Init是之前设计的,用来告诉目标机软件,现在主机发来了设备查找信息
- SendConfirm,用来告诉目标机,主机想要想目标机发送文件,请求确认
主机部分
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
|
class DeviceDiscoverThread(QThread):
found_a_device = pyqtSignal(str, str, str)
allow_sending = pyqtSignal(bool)
device_discover_pack_received = pyqtSignal(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:
print("timeout")
pass
if m and address:
print(m)
recv_str = m.decode("utf-8") # pythonlibrary 192.168.1.100 on
recv_items = recv_str.split()
ip = address[0]
if recv_items[0] == 'Init':
self.device_discover_pack_received.emit('Init', ip)
elif recv_items[0] == 'SendConfirm':
self.device_discover_pack_received.emit('SendConfirm', ip)
elif recv_items[0] == 'SendMe':
self.allow_sending.emit(True)
elif recv_items[0] == 'NotSendMe':
self.allow_sending.emit(False)
else:
name = recv_items[1]
status = recv_items[2]
if name != socket.gethostname():
self.found_a_device.emit(name, ip, status)
self.s.close()
self.s = None
|
因此,在allow_sending的槽函数(send_permission)中,如果allow参数为True,则实例化发送线程,执行发送动作,如果allow参数为False,则创建一个弹出窗口,告知用户,目标机拒绝了接收。
我们采用了直接用代码实例化QMessageBox的方式创建这个图形对话框,而没有使用Qt Creator来事先创建ui文件,因为,弹出窗口一般很简单,不需要太多的功能,用代码来配置更为简单快速。
1
|
self.device_discover_thread.allow_sending.connect(self.send_permission)
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
@pyqtSlot(bool)
def send_permission(self, allow):
if allow:
self.socket_server_thread.running = False
self.socket_client_thread = SocketClientThread()
self.socket_client_thread.pass_para(self.url_list, self.target_ip)
self.socket_client_thread.progress_updated.connect(self.update_progress)
self.socket_client_thread.start()
else:
msg = QMessageBox()
msg.setIcon(QMessageBox.Information)
msg.setWindowTitle("WiFi Drop 拒绝接收")
msg.setText("拒绝接受")
msg.exec_()
|
目标机部分
device_discover_pack_received的槽函数(device_discover_pack_received)中,我们区分了Init命令和SendConfirm命令。当收到Init命令以后,跟原来一样,只需要向主机发送自己的IP地址就可以了,而当收到SendConfirm命令以后,我们同样的弹出一个窗口,要求用户通过按钮选择确认接收或者拒绝接收,当用户点击了确定以后,目标机向主机发送SendMe字符串,以及自身的IP地址,当用户点击了拒绝以后,目标机向主机发送NotSendMe字符串,以及自身IP地址。
通过按钮实现确认和拒绝的功能也通过按钮的click信号绑定到槽函数的方式实现。
1
|
self.device_discover_thread.device_discover_pack_received.connect(self.device_discover_pack_received)
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
@pyqtSlot(str, str)
def device_discover_pack_received(self, command, ip):
if command == 'Init':
self.target_ip = ip
hostname = socket.gethostname()
message = 'Exchange {} ServerOn'.format(hostname)
self.socket_broadcast.sendto(message.encode('utf-8'), (self.target_ip, 54545))
elif command == 'SendConfirm':
msg = QMessageBox()
msg.setIcon(QMessageBox.Information)
msg.setWindowTitle("WiFi Drop 接收确认")
msg.setText("确认接收?")
msg.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel)
msg.buttonClicked.connect(self.msgbtn)
msg.exec_()
|
按钮的槽函数
1
2
3
4
5
6
7
|
def msgbtn(self, i):
if i.text() == 'OK':
message = 'SendMe'
self.socket_broadcast.sendto(message.encode('utf-8'), (self.target_ip, 54545))
else:
message = 'NotSendMe'
self.socket_broadcast.sendto(message.encode('utf-8'), (self.target_ip, 54545))
|
1.3 最后一些简单的必要修改
我们本篇章以前代码中设计的主机和目标机的信息交互通讯模型,都是在发送窗口上实现的,当时将这部分socket线程放在发送窗口实例化了,因此主窗口并没有打开这部分通讯线程。而本章中引入的目标机功能都是用户没有打开发送窗口的情况下的使用场景,因此要求这个线程是随着软件启动而启动的。
同时,这个线程既需要向主窗口发送信号(目标机场景:要求用户确认接收),也需要向发送窗口发送信号(主机场景:收到用户确认或拒绝,执行发送或不发送动作),因此,其既要绑定到主窗口的函数,也要绑定到发送窗口的函数。
因为,发送窗口是在主窗口中实例化的,所以,我们可以将DeviceDiscoverThread线程的实例化从发送窗口移动到主窗口中,同时在发送窗口示例化的时候,将其作为参数传给发送串口。
更改后的主窗口初始化函数如下,添加了DeviceDiscoverThread的实例化,实例化的类放置在self.socket_server_thread中。
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
|
class MainWindow(QMainWindow):
"""Main window"""
def __init__(self):
super(MainWindow, self).__init__()
# UI setup - 1 option
# dynamic load ui for development purpose
self.ui = loadUi('./mainwindow.ui', self)
self.ui.setStatusBar(None) # https://doc.qt.io/qt-5/qmainwindow.html#setStatusBar
self.ui.pushButtonChoose.setStyleSheet("background-color:#ffffff;")
self.ui.pushButtonChoose.clicked.connect(self.pushButtonChoose_clicked)
self.ui.dropArea = DropArea("或 拖动文件或文件夹到这里", self)
self.ui.dropArea.files_dropped.connect(self.prepare_sending)
self.ui.horizontalLayout.addWidget(self.ui.dropArea)
spacerItem = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) # QtWidgets
self.ui.horizontalLayout.addItem(spacerItem)
self.socket_server_thread = SocketServerThread()
self.socket_server_thread.start()
self.device_discover_thread = DeviceDiscoverThread()
self.device_discover_thread.device_discover_pack_received.connect(self.device_discover_pack_received)
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)
|
同时在prepare_sending函数中的发送窗口实例化时,传入这个self.socket_server_thread对象。
1
2
|
senddiag = SendDialog(url_list, self.socket_server_thread, self.device_discover_thread, self.socket_broadcast)
senddiag.exec()
|
相应的,发送窗口要能够接收这些参数,因此对其初始化也做一个简单的修改如下
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, socket_server_thread, device_discover_thread, socket_broadcast):
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 = device_discover_thread
self.device_discover_thread.found_a_device.connect(self.update_devices)
self.device_discover_thread.allow_sending.connect(self.send_permission)
self.socket_broadcast = socket_broadcast
self.socket_broadcast.sendto('Init'.encode('utf-8'), ('255.255.255.255', 54545))
self.socket_server_thread = socket_server_thread
|
2. 结语
到这里,一个简单的模拟airdrop功能的软件就算是完成了,系列教程中使用到的所有代码都可以在github仓库 https://github.com/pythonlibrary/wifidrop
中找到。系列教程中涉及到了下边这些知识点,因此如果你想通过一个不是那么枯燥的方式了解这些知识点的话,可以按照本教程的顺序一步一步来实现这样一个小软件:
- GUI桌面应用编程的基本逻辑
- PyQt5实现一个应用需要掌握的基本概念和逻辑
- Socket的基本概念和逻辑
- 线程的基本概念和逻辑
最终的实现效果可参考本文开篇的两个视频。