基于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基本一遍就写对了代码,由于电脑蓝牙模块死机,需要重启,一直以为代码异常。