diff --git a/MR_Tool.py b/MR_Tool.py index c57524e..71f3e57 100644 --- a/MR_Tool.py +++ b/MR_Tool.py @@ -16,7 +16,12 @@ import numpy as np import pandas as pd import requests import webbrowser - +import serial +import serial.tools.list_ports +from PyQt5.QtWidgets import QGroupBox, QGridLayout, QLineEdit, QTextBrowser +from PyQt5.QtCore import QTimer, pyqtSlot +from PyQt5.QtWidgets import QCheckBox +from PyQt5.QtCore import QTimer def resource_path(relative_path): """兼容PyInstaller打包后资源路径""" @@ -735,7 +740,607 @@ class DownloadPage(QWidget): main_layout.addWidget(footer) # --------- 功能三:串口助手 --------- -# class SerialAssistant(QWidget): +class SerialAssistant(QWidget): + def __init__(self): + super().__init__() + self.setFont(QFont("微软雅黑", 15)) + self.ser = None + self.timer = None + self.recv_buffer = b"" + self.plot_data = {} + self.curve_colors = ["#e74c3c", "#2980b9", "#27ae60", "#f1c40f", "#8e44ad", "#16a085"] + self.data_types = ["float", "int16", "uint16", "int8", "uint8"] + self.data_type = "float" + self.data_count = 2 + self.sample_idx = 0 + + # 新增:HEX模式复选框 + self.hex_send_chk = QCheckBox("HEX发送") + self.hex_recv_chk = QCheckBox("HEX接收") + self.hex_send_chk.setFont(QFont("微软雅黑", 10)) + self.hex_recv_chk.setFont(QFont("微软雅黑", 10)) + self.hex_send_chk.setChecked(False) + self.hex_recv_chk.setChecked(False) + + # 主体布局 + main_layout = QHBoxLayout(self) + main_layout.setContentsMargins(32, 32, 32, 32) + main_layout.setSpacing(28) + + # 左侧面板 + left_panel = QVBoxLayout() + left_panel.setSpacing(20) + left_panel.setContentsMargins(0, 0, 0, 0) + + # 串口配置区 + config_group = QGroupBox("串口配置") + config_group.setFont(QFont("微软雅黑", 14, QFont.Bold)) + config_group.setStyleSheet(""" + QGroupBox { + border: 2px solid #b5d0ea; + border-radius: 12px; + margin-top: 12px; + background: #f8fbfd; + color: #2471a3; + padding: 8px 0 0 0; + } + QGroupBox:title { + subcontrol-origin: margin; + left: 16px; + top: -8px; + background: transparent; + padding: 0 8px; + } + """) + config_layout = QGridLayout() + config_layout.setSpacing(12) + config_layout.setContentsMargins(16, 16, 16, 16) + config_layout.addWidget(QLabel("串口号:"), 0, 0) + self.port_box = QComboBox() + self.port_box.setMinimumWidth(120) + self.refresh_ports() + config_layout.addWidget(self.port_box, 0, 1) + config_layout.addWidget(QLabel("波特率:"), 1, 0) + self.baud_box = QComboBox() + self.baud_box.addItems(["9600", "115200", "57600", "38400", "19200", "4800"]) + self.baud_box.setCurrentText("115200") + config_layout.addWidget(self.baud_box, 1, 1) + self.refresh_btn = QPushButton("刷新串口") + self.refresh_btn.clicked.connect(self.refresh_ports) + self.refresh_btn.setStyleSheet(self._btn_style()) + config_layout.addWidget(self.refresh_btn, 2, 0) + self.open_btn = QPushButton("打开串口") + self.open_btn.setCheckable(True) + self.open_btn.clicked.connect(self.toggle_serial) + self.open_btn.setStyleSheet(self._btn_style("#27ae60")) + config_layout.addWidget(self.open_btn, 2, 1) + config_group.setLayout(config_layout) + left_panel.addWidget(config_group) + + # 数据协议配置区 + proto_group = QGroupBox("数据协议配置") + proto_group.setFont(QFont("微软雅黑", 14, QFont.Bold)) + proto_group.setStyleSheet(config_group.styleSheet()) + proto_layout = QGridLayout() + proto_layout.setSpacing(12) + proto_layout.setContentsMargins(16, 16, 16, 16) + proto_layout.addWidget(QLabel("数据数量:"), 0, 0) + self.data_count_spin = QSpinBox() + self.data_count_spin.setRange(1, 16) + self.data_count_spin.setValue(self.data_count) + self.data_count_spin.valueChanged.connect(self.apply_proto_config) + proto_layout.addWidget(self.data_count_spin, 0, 1) + proto_layout.addWidget(QLabel("数据类型:"), 1, 0) + self.data_type_box = QComboBox() + self.data_type_box.addItems(self.data_types) + self.data_type_box.setCurrentText(self.data_type) + self.data_type_box.currentTextChanged.connect(self.apply_proto_config) + proto_layout.addWidget(self.data_type_box, 1, 1) + proto_group.setLayout(proto_layout) + left_panel.addWidget(proto_group) + + # 发送数据区 + send_group = QGroupBox("发送数据") + send_group.setFont(QFont("微软雅黑", 14, QFont.Bold)) + send_group.setStyleSheet(""" + QGroupBox { + border: 2px solid #b5d0ea; + border-radius: 12px; + margin-top: 12px; + background: #f8fbfd; + color: #2471a3; + padding: 8px 0 0 0; + } + QGroupBox:title { + subcontrol-origin: margin; + left: 16px; + top: -8px; + background: transparent; + padding: 0 8px; + } + """) + send_layout = QVBoxLayout() + send_layout.setSpacing(14) + send_layout.setContentsMargins(12, 12, 12, 12) + + # 输入框(更大更高字体) + self.send_edit = QLineEdit() + self.send_edit.setFont(QFont("Consolas", 22)) + self.send_edit.setPlaceholderText("输入要发送的数据...") + self.send_edit.setMinimumHeight(54) + self.send_edit.setMinimumWidth(360) + self.send_edit.setStyleSheet(""" + QLineEdit { + background: #f8fbfd; + border-radius: 10px; + border: 1.5px solid #d6eaf8; + font-size: 22px; + padding: 12px 18px; + } + """) + send_layout.addWidget(self.send_edit) + + # HEX复选框行(字体更小) + hex_chk_row = QHBoxLayout() + self.hex_send_chk.setFont(QFont("微软雅黑", 10)) + self.hex_recv_chk.setFont(QFont("微软雅黑", 10)) + for chk in [self.hex_send_chk, self.hex_recv_chk]: + chk.setStyleSheet(""" + QCheckBox { + color: #2471a3; + spacing: 12px; + } + QCheckBox::indicator { + width: 18px; + height: 18px; + } + QCheckBox::indicator:checked { + background-color: #2980b9; + border: 1.5px solid #2980b9; + } + QCheckBox::indicator:unchecked { + background-color: #fff; + border: 1.5px solid #b5d0ea; + } + """) + self.hex_send_chk.setChecked(False) + self.hex_recv_chk.setChecked(False) + hex_chk_row.addWidget(self.hex_send_chk) + hex_chk_row.addWidget(self.hex_recv_chk) + hex_chk_row.addStretch(1) + send_layout.addLayout(hex_chk_row) + + # 发送按钮 + send_btn_row = QHBoxLayout() + self.send_btn = QPushButton("发送") + self.send_btn.clicked.connect(self.send_data) + self.send_btn.setFont(QFont("微软雅黑", 16, QFont.Bold)) + self.send_btn.setStyleSheet(self._btn_style("#2980b9")) + send_btn_row.addWidget(self.send_btn) + send_layout.addLayout(send_btn_row) + + send_group.setLayout(send_layout) + left_panel.addWidget(send_group, stretch=1) # 让发送区弹性填充 + + # 清空按钮 + self.clear_btn = QPushButton("清空接收和曲线") + self.clear_btn.clicked.connect(self.clear_all) + self.clear_btn.setStyleSheet(self._btn_style("#e74c3c")) + left_panel.addWidget(self.clear_btn) + + # 弹性空隙(动态扩展填满) + left_panel.addStretch(1) + + # 使用说明始终在最下方 + usage_group = QGroupBox("使用说明") + usage_group.setFont(QFont("微软雅黑", 13, QFont.Bold)) + usage_group.setStyleSheet(""" + QGroupBox { + border: 2px solid #b5d0ea; + border-radius: 10px; + margin-top: 10px; + background: #f8fbfd; + color: #2471a3; + padding: 6px 0 0 0; + } + QGroupBox:title { + subcontrol-origin: margin; + left: 10px; + top: -8px; + background: transparent; + padding: 0 6px; + } + """) + usage_layout = QVBoxLayout() + usage_label = QLabel( + "1. 选择串口号和波特率,点击“打开串口”。\n" + "2. 在“数据协议配置”中选择数据数量和数据类型。\n" + "3. 下位机发送格式:\n" + " 0x55 + 数据数量(1字节) + 数据 + 校验和(1字节)\n" + " 校验和为包头到最后一个数据字节的累加和的低8位。\n" + "4. 每包数据自动绘制曲线,X轴为采样点(或时间),Y轴为各通道数据。\n" + "5. 支持float/int16/uint16/int8/uint8类型,最多16通道。\n" + "6. 可点击“测试绘图”按钮模拟数据包接收效果。" + ) + usage_label.setWordWrap(True) + usage_label.setFont(QFont("微软雅黑", 9)) + usage_layout.addWidget(usage_label) + usage_group.setLayout(usage_layout) + left_panel.addWidget(usage_group) + + main_layout.addLayout(left_panel, 0) + + # 右侧面板 + right_panel = QVBoxLayout() + right_panel.setSpacing(20) + + # 接收区 + recv_group = QGroupBox("串口接收区") + recv_group.setFont(QFont("微软雅黑", 14, QFont.Bold)) + recv_group.setStyleSheet(config_group.styleSheet()) + recv_layout = QVBoxLayout() + self.recv_box = QTextEdit() + self.recv_box.setFont(QFont("Consolas", 13)) + self.recv_box.setReadOnly(True) + self.recv_box.setMinimumHeight(120) + self.recv_box.setStyleSheet(""" + QTextEdit { + background: #f8fbfd; + border-radius: 10px; + border: 1px solid #d6eaf8; + font-size: 15px; + color: #2c3e50; + padding: 8px; + } + """) + recv_layout.addWidget(self.recv_box) + recv_group.setLayout(recv_layout) + right_panel.addWidget(recv_group) + + # 曲线绘图区 + plot_frame = QFrame() + plot_frame.setStyleSheet(""" + QFrame { + background: #fff; + border-radius: 16px; + border: 1px solid #d6eaf8; + } + """) + plot_shadow = QGraphicsDropShadowEffect(self) + plot_shadow.setBlurRadius(18) + plot_shadow.setOffset(0, 4) + plot_shadow.setColor(Qt.gray) + plot_frame.setGraphicsEffect(plot_shadow) + plot_layout2 = QVBoxLayout(plot_frame) + plot_layout2.setContentsMargins(10, 10, 10, 10) + self.figure = Figure(figsize=(7, 4)) + self.canvas = FigureCanvas(self.figure) + plot_layout2.addWidget(self.canvas) + right_panel.addWidget(plot_frame, 2) + + main_layout.addLayout(right_panel, 1) + + # 定时器接收 + self.timer = QTimer(self) + self.timer.timeout.connect(self.read_serial) + + # 新增:正弦波测试定时器 + self.sine_timer = QTimer(self) + self.sine_timer.timeout.connect(self.send_sine_data) + self.sine_phase = 0 + # 默认配置 + self.apply_proto_config() + + + def simulate_data(self): + """模拟一包数据并自动解析绘图""" + import struct + import random + # 构造协议包 + head = 0x55 + count = self.data_count + dtype = self.data_type + # 随机生成数据 + if dtype == "float": + vals = [random.uniform(-10, 10) for _ in range(count)] + data_bytes = struct.pack(f"<{count}f", *vals) + elif dtype == "int16": + vals = [random.randint(-30000, 30000) for _ in range(count)] + data_bytes = struct.pack(f"<{count}h", *vals) + elif dtype == "uint16": + vals = [random.randint(0, 65535) for _ in range(count)] + data_bytes = struct.pack(f"<{count}H", *vals) + elif dtype == "int8": + vals = [random.randint(-128, 127) for _ in range(count)] + data_bytes = struct.pack(f"<{count}b", *vals) + elif dtype == "uint8": + vals = [random.randint(0, 255) for _ in range(count)] + data_bytes = struct.pack(f"<{count}B", *vals) + else: + vals = [0] * count + data_bytes = b"\x00" * (count * self._type_size()) + # 拼包 + pkt = bytes([head, count]) + data_bytes + checksum = sum(pkt) & 0xFF + pkt += bytes([checksum]) + # 加入接收缓冲区并解析 + self.recv_buffer += pkt + self.parse_and_plot_bin() + + def _btn_style(self, color="#2980b9"): + return f""" + QPushButton {{ + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 #eaf6fb, stop:1 #d6eaf8); + color: {color}; + border-radius: 14px; + font-size: 16px; + font-weight: 600; + padding: 8px 0; + border: 1.5px solid #d6eaf8; + letter-spacing: 1px; + }} + QPushButton:hover {{ + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 #f8fffe, stop:1 #cfe7fa); + color: #1a6fae; + border: 2px solid #b5d0ea; + }} + QPushButton:pressed {{ + background: #e3f0fa; + color: {color}; + border: 2px solid #a4cbe3; + }} + """ + + def apply_proto_config(self): + self.data_count = self.data_count_spin.value() + self.data_type = self.data_type_box.currentText() + self.plot_data = {i: [[], []] for i in range(self.data_count)} + self.sample_idx = 0 + self.update_plot() + + def refresh_ports(self): + self.port_box.clear() + ports = serial.tools.list_ports.comports() + for port in ports: + self.port_box.addItem(port.device) + if self.port_box.count() == 0: + self.port_box.addItem("无可用串口") + + def toggle_serial(self): + if self.open_btn.isChecked(): + port = self.port_box.currentText() + baud = int(self.baud_box.currentText()) + try: + self.ser = serial.Serial(port, baud, timeout=0.1) + self.open_btn.setText("关闭串口") + self.recv_box.append(f"已打开串口 {port} @ {baud}bps") + self.timer.start(50) + except Exception as e: + self.recv_box.append(f"打开串口失败: {e}") + self.open_btn.setChecked(False) + else: + if self.ser and self.ser.is_open: + self.ser.close() + self.open_btn.setText("打开串口") + self.recv_box.append("串口已关闭") + self.timer.stop() + + def send_data(self): + if self.ser and self.ser.is_open: + data = self.send_edit.text() + try: + if self.hex_send_chk.isChecked(): + # HEX模式发送 + data_bytes = bytes.fromhex(data.replace(' ', '')) + self.ser.write(data_bytes) + self.recv_box.append(f"发送(HEX): {data_bytes.hex(' ').upper()}") + else: + self.ser.write(data.encode('utf-8')) + self.recv_box.append(f"发送: {data}") + except Exception as e: + self.recv_box.append(f"发送失败: {e}") + else: + self.recv_box.append("串口未打开,无法发送。") + + def send_multi_data(self): + if self.ser and self.ser.is_open: + text = self.send_edit.text() + lines = text.split(";") + for line in lines: + if line.strip(): + try: + if self.hex_send_chk.isChecked(): + data_bytes = bytes.fromhex(line.strip().replace(' ', '')) + self.ser.write(data_bytes) + self.recv_box.append(f"发送(HEX): {data_bytes.hex(' ').upper()}") + else: + self.ser.write(line.strip().encode('utf-8')) + self.recv_box.append(f"发送: {line.strip()}") + except Exception as e: + self.recv_box.append(f"发送失败: {e}") + else: + self.recv_box.append("串口未打开,无法发送。") + + def read_serial(self): + if self.ser and self.ser.is_open: + try: + data = self.ser.read_all() + if data: + if self.hex_recv_chk.isChecked(): + self.recv_box.append(f"接收(HEX): {data.hex(' ').upper()}") + else: + try: + self.recv_box.append(f"接收: {data.decode('utf-8', errors='replace')}") + except Exception: + self.recv_box.append(f"接收(HEX): {data.hex(' ').upper()}") + self.recv_buffer += data + self.parse_and_plot_bin() + except Exception as e: + self.recv_box.append(f"接收失败: {e}") + + def toggle_sine_test(self): + if self.test_btn.isChecked(): + self.test_btn.setText("停止测试") + self.sine_phase = 0 + self.sine_timer.start(80) # 80ms周期 + else: + self.test_btn.setText("测试正弦波(持续)") + self.sine_timer.stop() + + def send_sine_data(self): + import struct, math + head = 0x55 + count = self.data_count + dtype = self.data_type + t = self.sine_phase + vals = [] + for i in range(count): + # 多通道不同相位 + val = math.sin(t / 10.0 + i * math.pi / 4) * 10 + if dtype == "float": + vals.append(float(val)) + elif dtype == "int16": + vals.append(int(val * 1000)) + elif dtype == "uint16": + vals.append(int(val * 1000 + 20000)) + elif dtype == "int8": + vals.append(int(val * 10)) + elif dtype == "uint8": + vals.append(int(val * 10 + 100)) + # 打包 + if dtype == "float": + data_bytes = struct.pack(f"<{count}f", *vals) + elif dtype == "int16": + data_bytes = struct.pack(f"<{count}h", *vals) + elif dtype == "uint16": + data_bytes = struct.pack(f"<{count}H", *vals) + elif dtype == "int8": + data_bytes = struct.pack(f"<{count}b", *vals) + elif dtype == "uint8": + data_bytes = struct.pack(f"<{count}B", *vals) + else: + data_bytes = b"\x00" * (count * self._type_size()) + pkt = bytes([head, count]) + data_bytes + checksum = sum(pkt) & 0xFF + pkt += bytes([checksum]) + # 直接走接收流程模拟 + self.recv_buffer += pkt + self.parse_and_plot_bin() + # 在接收区实时显示理论值 + self.recv_box.append(f"理论: {['%.3f'%v for v in vals]}") + self.sine_phase += 1 + + def parse_and_plot_bin(self): + # 协议:0x55 + 数据数量(1B) + 数据 + 校验(1B) + min_len = 1 + 1 + self.data_count * self._type_size() + 1 + while len(self.recv_buffer) >= min_len: + idx = self.recv_buffer.find(b'\x55') + if idx == -1: + self.recv_buffer = b"" + break + if idx > 0: + self.recv_buffer = self.recv_buffer[idx:] + if len(self.recv_buffer) < min_len: + break + # 检查数量 + count = self.recv_buffer[1] + if count != self.data_count: + self.recv_buffer = self.recv_buffer[2:] + continue + data_bytes = self.recv_buffer[2:2+count*self._type_size()] + checksum = self.recv_buffer[2+count*self._type_size()] + calc_sum = (sum(self.recv_buffer[:2+count*self._type_size()])) & 0xFF + if checksum != calc_sum: + self.recv_box.append("校验和错误,丢弃包") + self.recv_buffer = self.recv_buffer[1:] + continue + # 解析数据 + values = self._unpack_data(data_bytes, count) + self.recv_box.append(f"接收: {values}") + for i, v in enumerate(values): + self.plot_data[i][0].append(self.sample_idx) + self.plot_data[i][1].append(v) + if len(self.plot_data[i][0]) > 200: + self.plot_data[i][0].pop(0) + self.plot_data[i][1].pop(0) + self.sample_idx += 1 + self.recv_buffer = self.recv_buffer[min_len:] + self.update_plot() + + def _type_size(self): + if self.data_type == "float": + return 4 + elif self.data_type in ("int16", "uint16"): + return 2 + elif self.data_type in ("int8", "uint8"): + return 1 + return 4 + + def _unpack_data(self, data_bytes, count): + import struct + fmt = { + "float": f"<{count}f", + "int16": f"<{count}h", + "uint16": f"<{count}H", + "int8": f"<{count}b", + "uint8": f"<{count}B" + }[self.data_type] + try: + return struct.unpack(fmt, data_bytes) + except Exception: + return [0] * count + + def update_plot(self): + self.figure.clear() + ax = self.figure.add_subplot(111) + ax.set_xlabel("采样点", fontsize=14) + ax.set_ylabel("数据值", fontsize=14) + has_curve = False + for idx in range(self.data_count): + color = self.curve_colors[idx % len(self.curve_colors)] + x_list, y_list = self.plot_data.get(idx, ([], [])) + if x_list and y_list: + ax.plot(x_list, y_list, label=f"CH{idx+1}", color=color, linewidth=2) + has_curve = True + if has_curve: + ax.legend() + ax.grid(True, linestyle="--", alpha=0.5) + self.canvas.draw() + + def clear_all(self): + self.recv_box.clear() + self.plot_data = {i: [[], []] for i in range(self.data_count)} + self.sample_idx = 0 + self.update_plot() + +# --------- 功能四:MRobot架构生成 --------- +class GenerateMRobotCode(QWidget): + def __init__(self): + super().__init__() + self.setFont(QFont("微软雅黑", 15)) + self.setStyleSheet(""" + QWidget { + background: #f8fbfd; + border-radius: 16px; + padding: 20px; + } + """) + self.init_ui() + + def init_ui(self): + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(20, 20, 20, 20) + main_layout.setSpacing(20) + + # 功能说明 + desc_label = QLabel("MRobot架构生成工具,帮助您快速生成MRobot项目代码。") + desc_label.setFont(QFont("微软雅黑", 14)) + desc_label.setAlignment(Qt.AlignCenter) + main_layout.addWidget(desc_label) + + # 其他UI元素... # --------- 主工具箱UI --------- class ToolboxUI(QWidget): @@ -794,7 +1399,7 @@ class ToolboxUI(QWidget): left_layout.addWidget(logo_label) # 按钮区 - self.button_names = ["主页", "曲线拟合", "功能三", "软件指南"] + self.button_names = ["主页", "曲线拟合", "Mini串口助手", "MR架构配置","软件指南"] self.buttons = [] for idx, name in enumerate(self.button_names): btn = QPushButton(name) @@ -865,8 +1470,9 @@ class ToolboxUI(QWidget): self.page_widgets = { 0: HomePage(), # 主页 1: PolyFitApp(), # 多项式拟合 - 2: self.placeholder_page("功能三开发中..."), - 3: DownloadPage(), # 下载页面 + 2: SerialAssistant(), # 串口助手 + 3: GenerateMRobotCode(), # MRobot架构生成 + 4: DownloadPage(), # 下载页面 } for i in range(len(self.button_names)): self.stack.addWidget(self.page_widgets[i])