PyQt5教程4 – 发现局域网的其他客户端

  • xhyl
  • January 29, 2020

到目前为止我们已经有了比较完整的用户交互界面,在使用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上收到的信息,包括:

  • 机器B发回来的响应搜索
  • 机器B罚回来的确认消息

而做通讯监控要定期的去接收和判断所收到的消息是否格式正确,是会阻塞线程的。为了确保程序能正常的执行和响应,我们需要新建一个线程来专门做这一部分的工作,这里起名叫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

3.3.更新tableWidget中的设备列表

前边已经将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,需要将它放到局域网内的另外一个台机器上运行,我使用了一个台树莓派进行模拟,最终效果为:




系列文章传送门:

类似文章

PyQt5教程3 – 发送页面进度条,自定义信号槽和线程

PyQt5教程3 – 发送页面进度条,自定义信号槽和线程

  • xhyl
  • January 22, 2020

在airdrop,当用户想要发送文件出去的时候,需要选择目标机器或者用户,在随后的发送中还会显示发送进度,针对这一交互逻辑,在WiFidrop中,我们也将设计相同的交互逻辑,我们要增加一个发送页面,在这个页面上将能够显示我们可以发送的对象,以及一个进度条。 这一篇文章中,我们将继续在 PyQt5教程2:主页面和拖动 的基础上,加入发送页面,和一些相应的逻辑。

阅读更多
PyQt5教程2:主页面和拖动

PyQt5教程2:主页面和拖动

  • xhyl
  • January 15, 2020

这一篇文章中,我们要实现的是主界面和主界面上需要支持的一些功能,我们将使用Qt Creator来创建我们的基本主界面。 1. UI设计 打开Qt Creator,在文件菜单中选择“新建文件或项目”,因为我们只是用Qt Creator来设计UI,所以我们只用它来创建和编辑.

阅读更多
PyQt5教程1:项目介绍和环境搭建

PyQt5教程1:项目介绍和环境搭建

  • xhyl
  • January 13, 2020

编程从来都是一门实践性很强的技术,最好的学习方式就是动起手来 写在前面 关于如何使用PyQt(PyQt4或者PyQt5)在网上有很多简单或者详细的教程,但是我发现大部分的教程是从一个最简单的点展开,试图从一个Hello World讲起,手动创建控件,比如如何用代码创建一个按钮,如果在按钮上绑定一个事件,如何利用该事件输出文字到文字框等等。还有一些教程,非常详尽的讲解每一个控件具有哪些功能,并展示如何使用。前者更像是一本教材,准备由浅入深,但是很多人可能在Hello World之后就因为枯燥的示例被劝退,后者更像一本参考资料,很少希望从头到尾的学习完所有控件。

阅读更多