基于PyQt5的简易的BLE蓝牙通讯工具

基于PyQt5的简易的BLE蓝牙通讯工具


前言

一款window下使用的蓝牙上位机和nordic52系列芯片通讯工具


一、功能描述

1.工具可以发送数据给蓝牙模块
2.蓝牙模块收到数据转发给串口
3.串口发送数据给蓝牙模块
4.蓝牙模块透传数据给工具
5.可以选中文件发送,有进度条,可以自己修改组包逻辑,当前功能为字节数大于MTU(244)直接发送,也可以用异步模块asyncio做逻辑接到到固定数据后发送
6.UUID需要根据实际项目修改,没有放在界面上
提示:需要用到python3的bleak,PyQt5,asyncio模块

二、功能阐释

1.界面

在这里插入图片描述

2.源码

代码如下(示例):

import sys
import os
import binascii
import time
import asyncio
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from bleak import BleakClient, BleakScanner
from bleak.exc import BleakError
import qasync
import logging

class BLEFileSender(QMainWindow):
    def __init__(self):
        super().__init__()
        self.client = None
        self.device_list = []
        self.file_data = [None] * 3  # 存储3个文件的二进制数据
        self.current_packet = 0
        self.packet_size = 244  # Nordic典型MTU大小
        self.is_connected = False
        self.is_sending = False
        self.current_service_uuid = ""
        self.current_char_uuid = ""
        self.ack_received = asyncio.Event()
        self.notify_char_uuid = None  # 新增的notify特征UUID
        self.filter_name = ""  # 过滤条件
        self.initUI()

    def initUI(self):
        self.setWindowTitle('BLE文件传输工具 V1.0')
        self.setGeometry(300, 300, 1000, 800)
        self.setStyleSheet("""
            QMainWindow {
                background-color: #1a1a2e;
            }
            QWidget {
                background-color: #1a1a2e;
                color: #e6e6e6;
                font-family: 'Segoe UI';
                font-size: 12px;
            }
            QPushButton {
                background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
                    stop:0 #2a82da, stop:1 #1e5f9b);
                border: 1px solid #1e5f9b;
                border-radius: 5px;
                color: white;
                padding: 5px 15px;
                min-width: 80px;
            }
            QPushButton:hover {
                background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
                    stop:0 #3a92ea, stop:1 #2e6fab);
            }
            QPushButton:pressed {
                background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
                    stop:0 #1a72c4, stop:1 #154f7c);
            }
            QLineEdit, QTextEdit, QListWidget {
                background-color: #0d0d1a;
                border: 1px solid #2a82da;
                border-radius: 4px;
                padding: 5px;
                color: #e6e6e6;
            }
            QProgressBar {
                border: 1px solid #2a82da;
                border-radius: 5px;
                text-align: center;
                background: #0d0d1a;
                height: 15px;
            }
            QProgressBar::chunk {
                background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
                    stop:0 #2a82da, stop:1 #1e5f9b);
                border-radius: 4px;
            }
            QLabel#statusLabel {
                color: #2a82da;
                font-weight: bold;
                font-size: 14px;
            }
        """)
        # 主布局
        main_widget = QWidget()
        self.setCentralWidget(main_widget)
        layout = QVBoxLayout(main_widget)

        # 过滤条件输入框
        filter_layout = QHBoxLayout()
        self.filter_label = QLabel('过滤名称:')
        self.filter_label.setStyleSheet("font-weight: bold; color: #2a82da;")
        self.filter_input = QLineEdit('TW')
        self.filter_input.setPlaceholderText('输入设备名称关键字')
        self.filter_input.setStyleSheet("padding: 8px;")
        filter_layout.addWidget(self.filter_label)
        filter_layout.addWidget(self.filter_input)
        layout.addLayout(filter_layout)

        # 设备选择区域
        device_group = QGroupBox("蓝牙设备列表")
        device_group.setStyleSheet("""
            QGroupBox {
                border: 2px solid #2a82da;
                border-radius: 5px;
                margin-top: 10px;
                padding-top: 15px;
                color: #2a82da;
                font-weight: bold;
            }
        """)
        device_layout = QHBoxLayout(device_group)
        self.device_list_widget = QListWidget()
        self.device_list_widget.setStyleSheet("""
            QListWidget::item {
                padding: 8px;
                border-bottom: 1px solid #2a82da;
            }
            QListWidget::item:selected {
                background: #2a82da;
                color: white;
            }
        """)
        # 按钮区域(垂直排列)
        btn_layout = QVBoxLayout()
        btn_layout.setSpacing(10)
        buttons = [
            ('扫描设备', 'qrc:/icons/scan.png'),
            ('连接', 'qrc:/icons/connect.png'),
            ('断开', 'qrc:/icons/disconnect.png'),
            ('刷新状态', 'qrc:/icons/refresh.png')
        ]

        self.scan_btn = self.create_icon_button('扫描设备', '#2a82da')
        self.connect_btn = self.create_icon_button('连接', '#27ae60')
        self.disconnect_btn = self.create_icon_button('断开', '#c0392b')
        self.reflash_connect_btn = self.create_icon_button('刷新状态', '#8e44ad')

        # 连接状态指示灯
        self.connection_led = QLabel()
        self.connection_led.setFixedSize(25, 25)
        self.update_connection_led(False)

        # UUID显示(添加背景)
        self.uuid_label = QLabel()
        self.uuid_label.setStyleSheet("""
            background: #0d0d1a;
            padding: 10px;
            border-radius: 5px;
            border: 1px solid #2a82da;
        """)
        self.update_uuid_display()

        # 组装按钮区域
        btn_layout.addWidget(self.scan_btn)
        btn_layout.addWidget(self.connect_btn)
        btn_layout.addWidget(self.disconnect_btn)
        btn_layout.addWidget(self.reflash_connect_btn)
        btn_layout.addWidget(self.connection_led)
        btn_layout.addStretch()
        btn_layout.addWidget(QLabel("服务信息:"))
        btn_layout.addWidget(self.uuid_label)


        device_layout.addWidget(self.device_list_widget, 70)
        device_layout.addLayout(btn_layout, 30)
        layout.addWidget(device_group)

        # 文件传输区域(带图标)
        file_group = QGroupBox("文件传输管理")
        file_group.setStyleSheet(device_group.styleSheet())
        file_layout = QVBoxLayout(file_group)

        # 文件按钮行
        file_btn_layout = QHBoxLayout()
        self.file_buttons = []
        for i, text in enumerate([
            '加载固件',
            '加载参数',
            '加载标定文件'
        ]):
            btn = self.create_icon_button(text, '#16a085')
            btn.clicked.connect(lambda _, idx=i: self.load_file(idx))
            self.file_buttons.append(btn)
            file_btn_layout.addWidget(btn)

        # 十六进制发送区域
        hex_layout = QHBoxLayout()
        self.hex_input = QLineEdit()
        self.hex_input.setPlaceholderText("输入HEX数据(如AA BB CC DD)...")
        self.send_hex_btn = self.create_icon_button('发送数据', '#2980b9')
        hex_layout.addWidget(self.hex_input)
        hex_layout.addWidget(self.send_hex_btn)

        file_layout.addLayout(file_btn_layout)
        file_layout.addLayout(hex_layout)
        layout.addWidget(file_group)

        # 进度条
        self.progress = QProgressBar()
        self.progress.setAlignment(Qt.AlignCenter)
        layout.addWidget(self.progress)

        # 日志区域(带标题)
        log_group = QGroupBox("系统日志")
        log_group.setStyleSheet(device_group.styleSheet())
        log_layout = QVBoxLayout(log_group)
        self.log = QTextEdit()
        self.log.setStyleSheet("font-family: 'Consolas'; font-size: 11px;")
        log_layout.addWidget(self.log)
        layout.addWidget(log_group)

        # 控制按钮(底部居中)
        self.send_btn = self.create_icon_button('开始传输', '#27ae60', size=(120, 40))
        layout.addWidget(self.send_btn, 0, Qt.AlignCenter)

        # 连接信号
        self.scan_btn.clicked.connect(self.scan_devices)
        self.connect_btn.clicked.connect(self.connect_device)
        self.disconnect_btn.clicked.connect(self.disconnect_device)
        self.reflash_connect_btn.clicked.connect(self.reflash_connect_device)
        self.send_btn.clicked.connect(self.start_send)
        self.send_hex_btn.clicked.connect(self.send_hex_data)

    def create_icon_button(self, text, color, size=None):
        btn = QPushButton(text)
        btn.setStyleSheet(f"""
            QPushButton {{
                background: qradialgradient(cx:0.5, cy:0.5, radius: 0.5,
                    fx:0.5, fy:0.5, stop:0 {color}80, stop:1 {color});
                border: 1px solid {color}60;
                border-radius: 8px;
                color: white;
                padding: 8px 15px;
                font-weight: bold;
            }}
            QPushButton:hover {{
                background: qradialgradient(cx:0.5, cy:0.5, radius: 0.5,
                    fx:0.5, fy:0.5, stop:0 {color}90, stop:1 {color}80);
            }}
            QPushButton:pressed {{
                background: qradialgradient(cx:0.5, cy:0.5, radius: 0.5,
                    fx:0.5, fy:0.5, stop:0 {color}, stop:1 {color}80);
            }}
        """)
        if size:
            btn.setFixedSize(*size)
        return btn

    def update_connection_led(self, connected):
        # 添加发光效果
        color = QColor(0, 255, 0) if connected else QColor(255, 0, 0)
        effect = QGraphicsDropShadowEffect()
        effect.setColor(color)
        effect.setBlurRadius(15)
        effect.setOffset(0)

        self.connection_led.setGraphicsEffect(effect)
        # ... [其余绘制代码与之前相同] ...

    def update_uuid_display(self):
        self.uuid_label.setText(f"""
            <b>服务UUID:</b><br>{self.current_service_uuid}<br><br>
            <b>写特征:</b><br>{self.current_char_uuid}<br><br>
            <b>通知特征:</b><br>{self.notify_char_uuid or '未找到'}
        """)

    def update_connection_led(self, connected):
        color = QColor(0, 255, 0) if connected else QColor(255, 0, 0)
        pixmap = QPixmap(20, 20)
        pixmap.fill(Qt.transparent)
        painter = QPainter(pixmap)
        painter.setBrush(color)
        painter.setPen(Qt.NoPen)
        painter.drawEllipse(0, 0, 20, 20)
        painter.end()
        self.connection_led.setPixmap(pixmap)

    def log_message(self, message):
        timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
        self.log.append(f'[{timestamp}] {message}')

    @qasync.asyncSlot()
    async def scan_devices(self):
        self.device_list_widget.clear()
        self.device_list = []  # 重置设备列表
        self.log_message('开始扫描设备...')
        try:
            devices = await BleakScanner.discover()
            if devices is None:
                self.log_message('没有找到任何设备')
                return

            #self.device_list = devices
            filter_name = self.filter_input.text().strip()  # 获取过滤条件
            for d in devices:
                device_name = getattr(d, 'name', 'Unknown Device') or 'Unknown Device'
                if filter_name and filter_name.upper() not in device_name.upper():
                    continue
                self.device_list.append(d)
                self.device_list_widget.addItem(f'{device_name} ({d.address})')
                print(f"Found device: {device_name} ({d.address})")
            self.log_message(f'找到 {len(self.device_list)} 个设备')
        except Exception as e:
            self.log_message(f'扫描失败: {str(e)}')

    @qasync.asyncSlot()
    async def connect_device(self):
        selected = self.device_list_widget.currentRow()
        if selected < 0:
            return
        device = self.device_list[selected]
        self.log_message(f"连接设备中 {device}...")
        try:
            self.client = BleakClient(device.address)
            await self.client.connect()
            self.is_connected = await self.client.is_connected()
            self.update_connection_led(True)

            services = await self.client.get_services()
            target_service_uuid = "0000fff0-0000-1000-8000-00805f9b34fb"
            target_write_char_uuid = "0000fff2-0000-1000-8000-00805f9b34fb"
            target_notify_char_uuid = "0000fff1-0000-1000-8000-00805f9b34fb"

            self.current_char_uuid = ""
            self.notify_char_uuid = None

            for service in services:
                if service.uuid == target_service_uuid:
                    self.current_service_uuid = service.uuid
                    for char in service.characteristics:
                        if char.uuid == target_write_char_uuid and 'write' in char.properties:
                            self.current_char_uuid = char.uuid
                        elif char.uuid == target_notify_char_uuid and 'notify' in char.properties:
                            self.notify_char_uuid = char.uuid

            self.uuid_label.setText(
                f'UUID:\n服务: {self.current_service_uuid}\n'
                f'特征(写): {self.current_char_uuid}\n'
                f'特征(通知): {self.notify_char_uuid}'
            )

            # 启动通知服务
            if self.notify_char_uuid is not None:
                await self.client.start_notify(self.notify_char_uuid, self.notification_handler)
                self.log_message('开始通知服务,开始蓝牙接收功能!')

            self.log_message(f'已连接到 {device.name}')

            # 启用发送十六进制数据功能
            self.hex_input.setEnabled(True)
            self.send_hex_btn.setEnabled(True)
        except Exception as e:
            self.log_message(f'连接失败: {str(e)}')
            self.is_connected = False
            self.update_connection_led(False)

    @qasync.asyncSlot()
    async def disconnect_device(self):
        if self.client:
            try:
                await self.client.disconnect()
                self.log_message('设备已断开')
            except Exception as e:
                self.log_message(f'断开失败: {str(e)}')
            finally:
                self.is_connected = False
                self.update_connection_led(False)
                self.uuid_label.setText('UUID:\n服务: -\n特征: -')
                # 禁用发送十六进制数据功能
                self.hex_input.setEnabled(False)
                self.send_hex_btn.setEnabled(False)

    @qasync.asyncSlot()
    async def reflash_connect_device(self):
        if self.client:
            try:
                self.is_connected = await self.client.is_connected()
                if self.is_connected:
                    self.update_connection_led(True)
                else:
                    self.update_connection_led(False)
            except Exception as e:
                self.log_message(f'刷新失败: {str(e)}')
                self.is_connected = False
                self.update_connection_led(False)
                self.uuid_label.setText('UUID:\n服务: -\n特征: -')
                self.hex_input.setEnabled(False)
                self.send_hex_btn.setEnabled(False)

    def load_file(self, index):
        path, _ = QFileDialog.getOpenFileName(self, '选择文件', '', 'Bin Files (*.bin)')
        if path:
            try:
                with open(path, 'rb') as f:
                    self.file_data[index] = f.read()
                self.log_message(f'已加载文件 {index + 1}: {os.path.basename(path)}')
            except Exception as e:
                self.log_message(f'加载文件失败: {str(e)}')

    def process_data(self, file_index):
        header = bytes([0xAA, 0xBB, file_index + 1])
        footer = bytes([0xCC, 0xDD])
        hex_data = binascii.hexlify(self.file_data[file_index])
        return header + hex_data + footer

    async def wait_for_ack(self):
        try:
            await asyncio.wait_for(self.ack_received.wait(), timeout=2.0)
            self.ack_received.clear()
        except asyncio.TimeoutError:
            raise Exception("ACK超时")

    def notification_handler(self, sender, data):
        if data == b'\x55\xAA':
            self.ack_received.set()
        else:
            self.log_message(f'接收设备返回的数据: {data.hex()}')

    @qasync.asyncSlot()
    async def send_hex_data(self):
        if not self.is_connected or not await self.client.is_connected():
            self.log_message('蓝牙未连接,请重新连接')
            return

        hex_str = self.hex_input.text().strip()
        try:
            # 将十六进制字符串转换为字节
            data = bytes.fromhex(hex_str)
        except Exception as e:
            self.log_message(f'转换十六进制数据失败: {str(e)}')
            return

        try:
            # 发送数据
            await self.client.write_gatt_char(self.current_char_uuid, data, response=True)
            self.log_message(f'已发送十六进制数据: {hex_str}')
        except Exception as e:
            self.log_message(f'发送十六进制数据失败: {str(e)}')

    @qasync.asyncSlot()
    async def start_send(self):
        if not self.is_connected or not await self.client.is_connected():
            self.log_message('蓝牙未连接,请重新连接')
            self.update_connection_led(False)
            return

        if not any(self.file_data):
            self.log_message('请先加载至少一个文件')
            return

        try:
            for file_index in range(3):
                if not self.file_data[file_index]:
                    continue

                processed_data = self.process_data(file_index)
                total = len(processed_data)
                self.current_packet = 0
                self.progress.setMaximum(total)
                self.log_message(f'开始发送文件 {file_index + 1} ({total} bytes)')

                try:
                    while self.current_packet * self.packet_size < total:
                        if not await self.client.is_connected():
                            raise Exception("连接已断开")

                        start = self.current_packet * self.packet_size
                        end = start + self.packet_size
                        packet = processed_data[start:end]

                        await self.client.write_gatt_char(
                            self.current_char_uuid,
                            packet,
                            response=True
                        )
                        # await self.wait_for_ack()

                        self.current_packet += 1
                        self.progress.setValue(start)
                        QApplication.processEvents()

                    self.log_message(f'文件 {file_index + 1} 发送完成 ({end} bytes)')
                    self.progress.setValue(total)
                except Exception as e:
                    self.log_message(f'发送中断: {str(e)}')
                    break

        except Exception as e:
            self.log_message(f'发送失败: {str(e)}')
        finally:
            # # 停止通知服务
            # if self.notify_char_uuid is not None:
            #     try:
            #         await self.client.stop_notify(self.notify_char_uuid)
            #         self.log_message('停止通知服务,关闭蓝牙接收功能!')
            #     except Exception as e:
            #         self.log_message(f'停止通知服务失败: {str(e)}')
            self.send_btn.setEnabled(True)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    # 设置全局字体
    font = QFont("Segoe UI", 9)
    app.setFont(font)

    # 设置高DPI缩放
    app.setAttribute(Qt.AA_EnableHighDpiScaling)
    app.setAttribute(Qt.AA_UseHighDpiPixmaps)

    loop = qasync.QEventLoop(app)
    asyncio.set_event_loop(loop)
    window = BLEFileSender()
    window.show()
    with loop:
        loop.run_forever()

总结

开发过程中deepseek基本一遍就写对了代码,由于电脑蓝牙模块死机,需要重启,一直以为代码异常。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值