#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ SerialMate — RS485/串口调试助手(Fluent UI) ================================================== 帧格式五段式可定制: 包头(TX/RX) + 板ID + LEN(=payload 长度) + payload + CRC (算法/覆盖范围可选), 默认与 DLPilot 光机 RS485 协议一致(D5/B5/01/SUM8/不含包头)。 功能: - 串口收发, 参数热切换; 半双工 RS485 友好(每个工作周期最多写一帧, 留应答间隙) - 接收: 协议解析(包头+LEN 定界+CRC 校验, 坏帧重同步) / 原始透传 两模式切换 - payload 显示 字符串/HEX 切换, 原始帧 hex 可选, 日志可保存 - 曲线: 4 通道, 二进制定长 / 文本数字 / 正则 三种提取方式 + 倍率 - 发送: 自动包装(payload 即协议载荷, '/' 分隔多条依次组帧) / HEX 直发, 支持周期发送; 命令历史 ↑↓ 回溯 用法: python main.py 打开 GUI python main.py --seconds N N 秒后自动退出(冒烟测试) """ import argparse import html import queue import os import re import sys import time from PySide6.QtCore import QEvent, QMimeData, QRegularExpression, Qt, QTimer from PySide6.QtGui import (QFontDatabase, QIcon, QDrag, QPalette, QRegularExpressionValidator) from PySide6.QtWidgets import (QApplication, QDialog, QFileDialog, QHBoxLayout, QHeaderView, QInputDialog, QMainWindow, QMessageBox, QPlainTextEdit, QSlider, QSplitter, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget) from qfluentwidgets import (BodyLabel, CaptionLabel, CardWidget, CheckBox, ComboBox, DoubleSpinBox, EditableComboBox, FluentIcon, LineEdit, PlainTextEdit, PrimaryPushButton, PushButton, SpinBox, StrongBodyLabel, SubtitleLabel) import config from link import SerialLink from plot import PlotWidget, fmt_val from protocol import (BIN_FORMATS, CRC_ALGOS, CRC_ALGO_LABELS, CRC_SCOPE_LABELS, DEFAULT_LAYOUT, LEN_MAX, LEN_SEM_LABELS, LEN_SEMS, ProtoConfig, TYPE_NAMES, clean_text, extract_value, hexs, FrameLayout) from theme import ThemeManager APP_NAME = 'SerialMate' APP_VERSION = '0.1.0' REPO_URL = 'https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools' BAUDS = (1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600) TX_MODES = ('wrap', 'hex') TX_MODE_LABELS = ('自动包装 (ASCII payload)', 'HEX 直发') RX_DISPLAYS = ('text', 'hex') RX_DISPLAY_LABELS = ('字符串', 'HEX') CH_MODES = ('bin', 'num', 're') CH_MODE_LABELS = ('二进制', '文本数字', '正则') # 弹出曲线窗口的时间窗宽选项 (显示文本, 秒数; None=跟随全部) PLOT_SPANS = (('跟随(全部)', None), ('最近 10s', 10), ('最近 30s', 30), ('最近 1min', 60), ('最近 5min', 300), ('最近 15min', 900)) # fmt -> 显示文本(顺序即下拉顺序) FMT_LABELS = (('u8', 'u8'), ('s8', 's8'), ('u16be', 'u16 大端'), ('u16le', 'u16 小端'), ('s16be', 's16 大端'), ('s16le', 's16 小端'), ('u32be', 'u32 大端'), ('u32le', 'u32 小端'), ('s32be', 's32 大端'), ('s32le', 's32 小端'), ('f32be', 'f32 大端'), ('f32le', 'f32 小端')) def _timestamp(): t = time.time() return time.strftime('%H:%M:%S', time.localtime(t)) + f'.{int(t * 1000) % 1000:03d}' class CommandTable(QTableWidget): """命令预设表格: 第 0 列为拖动手柄(≡), 按住上下拖动整行排序; 网格线/隔行底色随明暗主题, 空行也清晰可见""" def __init__(self, commands, parent=None): super().__init__(len(commands), 3, parent) self.setColumnCount(3) self.setHorizontalHeaderLabels(['', '显示名称', '命令内容']) self.horizontalHeader().setSectionResizeMode(0, QHeaderView.Fixed) self.setColumnWidth(0, 36) self.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) self.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) self.verticalHeader().setVisible(False) self.verticalHeader().setDefaultSectionSize(34) self.setShowGrid(True) self.setGridStyle(Qt.SolidLine) self.setAlternatingRowColors(True) dark = QApplication.palette().color(QPalette.Base).lightness() < 128 grid, alt = ('#4d4d4d', '#303438') if dark else ('#c9cfd6', '#f3f5f8') self.setStyleSheet(f'QTableWidget {{ gridline-color: {grid}; ' f'alternate-background-color: {alt}; }}') self.setToolTip('行首 ≡ 按住上下拖动调整顺序; 双击单元格编辑') self.setAcceptDrops(True) self._drag_row = None for r, (name, cmd) in enumerate(commands): self._set_handle(r) self.setItem(r, 1, QTableWidgetItem(name)) self.setItem(r, 2, QTableWidgetItem(cmd)) def _set_handle(self, row): it = QTableWidgetItem('≡') it.setFlags(Qt.ItemIsEnabled) # 手柄列不可编辑不可选 it.setTextAlignment(Qt.AlignCenter) it.setToolTip('按住拖动调整顺序') self.setItem(row, 0, it) def _add_row(self): r = self.rowCount() self.insertRow(r) self._set_handle(r) self.setCurrentCell(r, 1) def _del_row(self): for r in sorted({i.row() for i in self.selectedIndexes()}, reverse=True): self.removeRow(r) def move_row(self, src, dst): """整行搬移(含手柄列); 越界/原地不动静默""" n = self.rowCount() if not (0 <= src < n) or not (0 <= dst < n) or src == dst: return items = [self.takeItem(src, c) for c in range(self.columnCount())] self.removeRow(src) self.insertRow(dst) for c, it in enumerate(items): self.setItem(dst, c, it) self.setCurrentCell(dst, 1) # --- 行首手柄发起的内部拖拽 --- def mousePressEvent(self, ev): if ev.button() == Qt.LeftButton: pos = ev.position().toPoint() if self.columnAt(pos.x()) == 0: self._drag_row = self.rowAt(pos.y()) if self._drag_row >= 0: drag = QDrag(self) mime = QMimeData() mime.setText(str(self._drag_row)) drag.setMimeData(mime) drag.exec(Qt.MoveAction) self._drag_row = None return super().mousePressEvent(ev) def _own_drag(self, e): return e.source() is self and self._drag_row is not None def dragEnterEvent(self, ev): if self._own_drag(ev): ev.accept() def dragMoveEvent(self, ev): if self._own_drag(ev): ev.accept() def dropEvent(self, ev): if not self._own_drag(ev): return ev.setDropAction(Qt.MoveAction) ev.accept() target = self.rowAt(ev.position().toPoint().y()) if target < 0: target = self.rowCount() - 1 self.move_row(self._drag_row, target) self._drag_row = None def commands(self): """收集表格 -> [[名称, 命令]..]; 空命令行忽略, 命令文本去重""" out = [] for r in range(self.rowCount()): it1, it2 = self.item(r, 1), self.item(r, 2) name = (it1.text() if it1 else '').strip() cmd = (it2.text() if it2 else '').strip() if cmd and not any(c[1] == cmd for c in out): out.append([name or cmd, cmd]) return out class ProfileEditDialog(QDialog): """板型命令预设表编辑器: CommandTable 显示 名称/命令, +加行 −删行, 保存写回""" def __init__(self, profile, parent=None): super().__init__(parent) self.setWindowTitle(f'编辑命令预设 · {profile["name"]}') self.resize(540, 440) lay = QVBoxLayout(self) self.table = CommandTable(profile['commands'], self) lay.addWidget(self.table) btns = QHBoxLayout() btn_add = PushButton('+ 添加行') btn_add.clicked.connect(self.table._add_row) btn_del = PushButton('− 删除选中行') btn_del.clicked.connect(self.table._del_row) btns.addWidget(btn_add) btns.addWidget(btn_del) btns.addStretch(1) btn_save = PrimaryPushButton('保存') btn_save.clicked.connect(self.accept) btn_cancel = PushButton('取消') btn_cancel.clicked.connect(self.reject) btns.addWidget(btn_save) btns.addWidget(btn_cancel) lay.addLayout(btns) def commands(self): return self.table.commands() class Window(QMainWindow): def __init__(self, theme_manager=None, seconds=0): super().__init__() self.setWindowTitle(f'{APP_NAME} · 串口/RS485 调试助手 v{APP_VERSION}') self.resize(1380, 860) self.setMinimumSize(1060, 640) self.theme_manager = theme_manager or ThemeManager(QApplication.instance()) self.cfg_store = config.load() self.events = queue.Queue() self._proto_err = '' self.profiles = config.load_profiles() # 板型档案(协议参数+命令预设) self._commands = [] self._plot_win = None # 曲线独立窗口(弹出后非 None) self.tx_history, self._hist_pos = [], -1 self._tx_n = self._rx_n = self._tx_b = self._rx_b = 0 self.link = SerialLink(cfg=self._proto_from_store(), on_status=lambda s: self.events.put(('status', s))) self.link.on_frame = lambda fr: self.events.put(('frame', fr)) self.link.on_raw = lambda b: self.events.put(('raw', b)) self.link.on_tx = lambda lb, d: self.events.put(('tx', lb, d)) self._build_ui() self.plot.on_yrange_changed = self._on_plot_yrange self.plot.on_span_changed = self._sync_time_slider self._apply_store_to_ui() self.theme_manager.set_window(self) self.ed_tx.installEventFilter(self) self._periodic = QTimer(self) self._periodic.timeout.connect(self._send) poll = QTimer(self) poll.timeout.connect(self._drain_events) poll.timeout.connect(self._sync_time_slider) poll.start(30) QTimer.singleShot(0, self._apply_minimum_size) # 首次布局后抬高窗口下限 if seconds: QTimer.singleShot(int(seconds * 1000), self.close) def _apply_minimum_size(self): """按内容实际最小高度抬高窗口下限: 窗口拉得过矮时布局约束无法满足, 曲线卡绘图区会被压塌并与通道行互相叠压(用户实测), 故禁止进入该区间""" hint = self.centralWidget().minimumSizeHint() self.setMinimumHeight(max(660, hint.height())) # ------------------------------------------------------------------ UI def _build_ui(self): root = QWidget(self) root.setObjectName('root') outer = QVBoxLayout(root) outer.setContentsMargins(18, 12, 18, 12) outer.setSpacing(10) head = QHBoxLayout() box = QVBoxLayout() box.setSpacing(1) box.addWidget(SubtitleLabel(APP_NAME)) box.addWidget(CaptionLabel(f'RS485/串口调试助手 · 帧格式可定制 · v{APP_VERSION}')) head.addLayout(box) head.addStretch(1) btn_about = PushButton('关于') btn_about.clicked.connect(self.show_about) head.addWidget(btn_about) self.combo_theme = ComboBox() for m in ('跟随系统', '浅色', '深色'): self.combo_theme.addItem(m) self.combo_theme.setCurrentIndex(0) self.combo_theme.currentIndexChanged.connect(self._on_theme) head.addWidget(BodyLabel('主题')) head.addWidget(self.combo_theme) outer.addLayout(head) splitter = QSplitter(Qt.Horizontal) left, right = QWidget(), QWidget() lcol, rcol = QVBoxLayout(left), QVBoxLayout(right) lcol.setContentsMargins(0, 0, 0, 0) rcol.setContentsMargins(0, 0, 0, 0) lcol.setSpacing(10) rcol.setSpacing(10) self._build_serial_card(lcol) self._build_proto_card(lcol) self._build_plot_card(lcol) rx_card = self._build_rx_card() tx_card = self._build_tx_card() lcol.addStretch(1) rsplit = QSplitter(Qt.Vertical) # 接收/发送高度用户可拖拽 rsplit.setHandleWidth(6) rsplit.setChildrenCollapsible(False) rsplit.addWidget(rx_card) rsplit.addWidget(tx_card) rsplit.setStretchFactor(0, 1) # 拉伸余量默认给接收区 rsplit.setStretchFactor(1, 0) # 发送卡保持自然高度(防空腔) rsplit.setSizes((600, 270)) rcol.addWidget(rsplit) self._on_tx_mode() splitter.addWidget(left) splitter.addWidget(right) splitter.setSizes((460, 900)) outer.addWidget(splitter, 1) self.setCentralWidget(root) self.statusBar().showMessage('未连接') def _card(self, title, subtitle=None, side=False): """Fluent 卡片; side=True 时副标题小字放标题右侧(省一行高度, 适合短副标题)""" card = CardWidget(self) lay = QVBoxLayout(card) lay.setContentsMargins(14, 12, 14, 14) lay.setSpacing(8) if subtitle and side: h = QHBoxLayout() h.setSpacing(10) h.addWidget(StrongBodyLabel(title, card)) h.addStretch(1) h.addWidget(CaptionLabel(subtitle, card)) lay.addLayout(h) return card, lay lay.addWidget(StrongBodyLabel(title, card)) if subtitle: c = CaptionLabel(subtitle, card) c.setWordWrap(True) lay.addWidget(c) return card, lay @staticmethod def _row(lay, *widgets, trailing=True): """紧凑行: qfluentwidgets 控件默认水平 Expanding, 不给 stretch 项时 空白会被它们瓜分出大空隙 —— 行尾默认补 stretch(Qt 规则: 有 stretch 因子时只按因子分配额外空间); 需要自管拉伸的行传 trailing=False。""" h = QHBoxLayout() h.setSpacing(6) for w in widgets: h.addWidget(w) if trailing: h.addStretch(1) lay.addLayout(h) return h # --- 串口卡 --- def _build_serial_card(self, col): card, lay = self._card('串口', 'RS485 半双工常用 115200 8N1', side=True) self.combo_port = ComboBox() self.combo_port.setMinimumWidth(150) self._port_devs = [] self.btn_refresh = PushButton('刷新') self.btn_refresh.setIcon(FluentIcon.SYNC) self.btn_refresh.clicked.connect(self._refresh_ports) self.combo_baud = EditableComboBox() for b in BAUDS: self.combo_baud.addItem(str(b)) self.btn_conn = PrimaryPushButton('连接') self.btn_conn.clicked.connect(self._toggle_connect) h = self._row(lay, self.combo_port, self.btn_refresh, self.combo_baud, self.btn_conn, trailing=False) h.setStretch(0, 1) # 端口下拉吃掉行内剩余宽度 self.combo_databits, self.combo_parity, self.combo_stop = ComboBox(), ComboBox(), ComboBox() for v in ('8', '7', '6', '5'): self.combo_databits.addItem(v) for v in ('N 无校验', 'E 偶校验', 'O 奇校验'): self.combo_parity.addItem(v) for v in ('1', '2'): self.combo_stop.addItem(v) self.lb_state = BodyLabel('未连接') self._row(lay, CaptionLabel('数据位'), self.combo_databits, CaptionLabel('校验'), self.combo_parity, CaptionLabel('停止位'), self.combo_stop, self.lb_state) col.addWidget(card) self._refresh_ports() def _refresh_ports(self): self._port_devs = SerialLink.list_ports_info() combo = self.combo_port combo.clear() for _dev, label in self._port_devs: combo.addItem(label) if not self._port_devs: combo.addItem('未检测到串口') def _toggle_connect(self): if self.link.is_connected(): self.link.close() self.btn_conn.setText('连接') return if not self._port_devs: self._hint('未检测到串口, 请插入适配器后刷新') return idx = self.combo_port.currentIndex() if not 0 <= idx < len(self._port_devs): self._hint('请选择串口') return dev = self._port_devs[idx][0] try: baud = int(self.combo_baud.currentText() or '115200') except ValueError: self._hint('波特率非法') return self.link.open(dev, baud, bytesize=int(self.combo_databits.currentText()), parity=self.combo_parity.currentText()[0], stopbits=int(self.combo_stop.currentText())) self.btn_conn.setText('断开') # --- 协议卡 --- def _build_proto_card(self, col): card, lay = self._card('协议帧格式', '帧构成令牌序列可定制, LEN/PAYLOAD/CRC 各一次且 CRC 在末尾', side=True) hexval = QRegularExpressionValidator(QRegularExpression('[0-9A-Fa-f]{0,2}')) self.ed_tx_hdr, self.ed_rx_hdr, self.ed_board = LineEdit(), LineEdit(), LineEdit() for ed, w in ((self.ed_tx_hdr, 46), (self.ed_rx_hdr, 46), (self.ed_board, 46)): ed.setValidator(hexval) ed.setFixedWidth(46) ed.setAlignment(Qt.AlignCenter) ed.textChanged.connect(self._apply_proto) self._row(lay, CaptionLabel('TX包头'), self.ed_tx_hdr, CaptionLabel('RX包头'), self.ed_rx_hdr, CaptionLabel('板ID'), self.ed_board) # 帧构成(发送/接收共用布局): HDR ID LEN PAYLOAD CRC 或十六进制字面量 self.ed_layout = LineEdit() self.ed_layout.setText(DEFAULT_LAYOUT) self.ed_layout.setFixedWidth(240) self.ed_layout.textChanged.connect(self._apply_proto) self.combo_len_sem = ComboBox() for label in LEN_SEM_LABELS.values(): self.combo_len_sem.addItem(label) self.combo_len_sem.currentIndexChanged.connect(self._apply_proto) self._row(lay, CaptionLabel('帧构成'), self.ed_layout, CaptionLabel('LEN口径'), self.combo_len_sem) self.combo_crc, self.combo_scope = ComboBox(), ComboBox() for label in CRC_ALGO_LABELS.values(): self.combo_crc.addItem(label) for label in CRC_SCOPE_LABELS.values(): self.combo_scope.addItem(label) self.combo_crc.currentIndexChanged.connect(self._apply_proto) self.combo_scope.currentIndexChanged.connect(self._apply_proto) self.chk_parse = CheckBox('解析协议帧(收)') self.chk_parse.stateChanged.connect(self._on_parse_toggle) self._row(lay, CaptionLabel('CRC'), self.combo_crc, CaptionLabel('覆盖'), self.combo_scope, self.chk_parse) self.lb_proto = CaptionLabel( '令牌: HDR=包头 ID=板ID LEN=长度 PAYLOAD=载荷 CRC=校验, ' '十六进制字面量(如 AA 55)为固定同步字节') self.lb_proto.setWordWrap(True) lay.addWidget(self.lb_proto) col.addWidget(card) def _proto_from_store(self): """启动时从配置构建 ProtoConfig(只调用一次, 其后以 UI 为准)""" p = self.cfg_store['proto'] try: return ProtoConfig(int(p['tx_hdr'], 16), int(p['rx_hdr'], 16), int(p['board'], 16), p['crc_algo'], p['crc_scope'], FrameLayout.parse(p.get('layout', DEFAULT_LAYOUT)), p.get('len_sem', 'payload')) except (ValueError, KeyError): return ProtoConfig() def _proto_from_ui(self): """读协议卡 -> ProtoConfig; 输入非法返回 None, 错误文本存 _proto_err""" try: return ProtoConfig(int(self.ed_tx_hdr.text() or 'D5', 16), int(self.ed_rx_hdr.text() or 'B5', 16), int(self.ed_board.text() or '01', 16), tuple(CRC_ALGOS)[self.combo_crc.currentIndex()], tuple(CRC_SCOPE_LABELS)[self.combo_scope.currentIndex()], FrameLayout.parse(self.ed_layout.text()), LEN_SEMS[self.combo_len_sem.currentIndex()]) except ValueError as e: self._proto_err = str(e) return None def _apply_proto(self, *_): cfg = self._proto_from_ui() if cfg is not None: self.link.set_config(cfg) self._update_preview() def _on_parse_toggle(self, state): self.link.set_parse(bool(state)) # --- 曲线卡 --- def _build_plot_card(self, col): card, lay = self._card('曲线', '每帧有效数据按通道提取一个值: 二进制=从偏移字节' '起按格式解; 文本数字=ASCII 第 N 个数字; 正则=第 1 捕获组;' ' 结果×倍率后画线') self.plot = PlotWidget(4) self._plot_slot = QHBoxLayout() # 绘图区固定槽位, 弹出/嵌回来回放 self._plot_slot.addWidget(self.plot) lay.addLayout(self._plot_slot, 1) self.btn_pause = PushButton('暂停') self.btn_pause.clicked.connect(self._toggle_pause) self.btn_plot_clear = PushButton('清空') self.btn_plot_clear.clicked.connect(self._clear_plot) self.btn_pop = PushButton('弹出') self.btn_pop.setToolTip('把绘图区弹成独立窗口(关掉窗口即嵌回)') self.btn_pop.clicked.connect(self._toggle_plot_window) self.chk_auto_y = CheckBox('自动Y') self.chk_auto_y.setChecked(True) self.chk_auto_y.stateChanged.connect(self._on_yrange) self.spin_ylo, self.spin_yhi = DoubleSpinBox(), DoubleSpinBox() for s, v in ((self.spin_ylo, 0.0), (self.spin_yhi, 100.0)): s.setRange(-1e9, 1e9) s.setDecimals(1) s.setValue(v) s.setEnabled(False) s.setFixedWidth(140) s.valueChanged.connect(self._on_yrange) self._row(lay, self.btn_pause, self.btn_plot_clear, self.btn_pop, self.chk_auto_y, CaptionLabel('Ymin'), self.spin_ylo, CaptionLabel('Ymax'), self.spin_yhi) # 4 个通道行: [使能][名称][提取方式][参数(随方式切换)][倍率][最新值] self._ch_ui = [] for i in range(4): ui = {} ui['chk'] = CheckBox() ui['chk'].toggled.connect(self._on_channel) ui['name'] = LineEdit() ui['name'].setFixedWidth(64) ui['name'].setToolTip('通道名称(图例显示)') ui['name'].textChanged.connect(self._on_channel) ui['mode'] = ComboBox() for label in CH_MODE_LABELS: ui['mode'].addItem(label) ui['mode'].setFixedWidth(100) ui['mode'].setToolTip('提取方式: 二进制=按字节位置解数值; 文本数字=' '取 ASCII 文本里第 N 个数字; 正则=自定义表达式') ui['mode'].currentIndexChanged.connect(lambda _i, u=ui: self._show_mode(u)) ui['mode'].currentIndexChanged.connect(self._on_channel) ui['w_bin'] = QWidget() hb = QHBoxLayout(ui['w_bin']) hb.setContentsMargins(0, 0, 0, 0) hb.setSpacing(4) ui['off'] = SpinBox() ui['off'].setRange(0, 250) ui['off'].setFixedWidth(110) ui['off'].setToolTip('起始偏移: 从 payload 第几个字节开始解(0=第 1 字节)') ui['off'].valueChanged.connect(self._on_channel) ui['fmt'] = ComboBox() for key, label in FMT_LABELS: ui['fmt'].addItem(label) ui['fmt'].setFixedWidth(108) ui['fmt'].setToolTip('解码格式: 位宽 / 有无符号 / 大小端') ui['fmt'].currentIndexChanged.connect(self._on_channel) hb.addWidget(CaptionLabel('偏移')) hb.addWidget(ui['off']) hb.addWidget(ui['fmt']) ui['w_num'] = QWidget() hb = QHBoxLayout(ui['w_num']) hb.setContentsMargins(0, 0, 0, 0) hb.setSpacing(4) ui['idx'] = SpinBox() ui['idx'].setRange(1, 64) ui['idx'].setFixedWidth(110) ui['idx'].setToolTip('取 payload 文本中第几个数字(1 起)') ui['idx'].valueChanged.connect(self._on_channel) hb.addWidget(CaptionLabel('第')) hb.addWidget(ui['idx']) hb.addWidget(CaptionLabel('个数字')) ui['w_re'] = QWidget() hb = QHBoxLayout(ui['w_re']) hb.setContentsMargins(0, 0, 0, 0) hb.setSpacing(4) ui['pat'] = LineEdit() ui['pat'].setFixedWidth(150) ui['pat'].setToolTip('正则表达式, 取第 1 捕获组(无组则取整体)') ui['pat'].textChanged.connect(self._on_channel) hb.addWidget(ui['pat']) ui['scale'] = DoubleSpinBox() ui['scale'].setRange(0.001, 1000000) ui['scale'].setDecimals(3) ui['scale'].setSingleStep(0.1) ui['scale'].setValue(1.0) ui['scale'].setFixedWidth(130) ui['scale'].setToolTip('倍率: 提取值 × 倍率 = 曲线值(单位换算用)') ui['scale'].valueChanged.connect(self._on_channel) ui['val'] = BodyLabel('—') ui['val'].setFixedWidth(78) ui['val'].setAlignment(Qt.AlignRight | Qt.AlignVCenter) ui['val'].setToolTip('通道最新值') h = self._row(lay, ui['chk'], ui['name'], ui['mode'], ui['w_bin'], ui['w_num'], ui['w_re'], ui['scale']) h.addWidget(ui['val']) # 最新值贴行尾, 其余控件靠左 self._show_mode(ui) self._ch_ui.append(ui) col.addWidget(card) def _show_mode(self, ui): mode = CH_MODES[ui['mode'].currentIndex()] ui['w_bin'].setVisible(mode == 'bin') ui['w_num'].setVisible(mode == 'num') ui['w_re'].setVisible(mode == 're') def _collect_channels(self): out = [] for ui in self._ch_ui: out.append({ 'enabled': ui['chk'].isChecked(), 'name': ui['name'].text().strip() or f"通道{len(out) + 1}", 'mode': CH_MODES[ui['mode'].currentIndex()], 'offset': ui['off'].value(), 'fmt': FMT_LABELS[ui['fmt'].currentIndex()][0], 'index': ui['idx'].value(), 'pattern': ui['pat'].text() or '(?<=:)\\d+', 'scale': ui['scale'].value(), }) return out def _on_channel(self, *_): for i, c in enumerate(self._collect_channels()): self.plot.set_channel_name(i, c['name'] if c['enabled'] else '') self._update_preview() def _toggle_pause(self): paused = not self.plot.is_paused() self.plot.set_paused(paused) self.btn_pause.setText('继续' if paused else '暂停') # --- 曲线独立窗口 --- def _toggle_plot_window(self): if self._plot_win is None: self._pop_plot() else: self._dock_plot() def _pop_plot(self): """绘图区弹成独立窗口; 通道/暂停/Y 轴控件留在主界面照常控制同一实例; 窗口底部带时间窗宽下拉与横向滑条(回看历史, 拉回最右=实时)""" self.plot.setParent(None) self._plot_win = QWidget(None, Qt.Window) self._plot_win.setWindowTitle(f'{APP_NAME} · 曲线') self._plot_win.resize(880, 580) self._plot_win.installEventFilter(self) # 捕获用户关窗 -> 自动嵌回 lay = QVBoxLayout(self._plot_win) lay.setContentsMargins(10, 10, 10, 10) lay.setSpacing(6) lay.addWidget(self.plot, 1) self.btn_freeze = PushButton('暂停渲染') self.btn_freeze.setToolTip('冻结画面查看/悬停读值; 采集继续, 恢复后一次性渲染积累的数据') self.btn_freeze.clicked.connect(self._toggle_freeze) self.btn_auto_y = PushButton('自动Y') self.btn_auto_y.setCheckable(True) self.btn_auto_y.setToolTip('自动缩放Y轴; 关闭后可在图上按住拖动自定义Y轴范围') self.btn_auto_y.clicked.connect(self._on_pop_auto_y) self.btn_live = PushButton('实时') self.btn_live.setToolTip('取消滞后, 跳回最新数据') self.btn_live.clicked.connect(self._go_live) self.combo_span = ComboBox() for label, _sec in PLOT_SPANS: self.combo_span.addItem(label) self.combo_span.setCurrentIndex(0) self.combo_span.setToolTip('可见时间窗宽度(图上滚轮也可缩放)') self.combo_span.currentIndexChanged.connect(self._on_span_change) self.slider_time = QSlider(Qt.Horizontal) self.slider_time.setRange(0, 1000) self.slider_time.setValue(1000) self.slider_time.setToolTip('左右拖动回看历史窗口; 拉到最右 = 实时跟随') self.slider_time.valueChanged.connect(self._on_time_slider) h = self._row(lay, self.btn_freeze, self.btn_auto_y, self.btn_live, CaptionLabel('时间窗'), self.combo_span, trailing=False) h.setStretch(4, 0) h.addWidget(self.slider_time, 1) self.btn_auto_y.setChecked(self.chk_auto_y.isChecked()) self._plot_win.show() self.btn_pop.setText('嵌回') self._sync_time_slider() def _toggle_freeze(self): frozen = not self.plot.is_frozen() self.plot.set_frozen(frozen) self.btn_freeze.setText('继续渲染' if frozen else '暂停渲染') def _on_pop_auto_y(self, checked): if checked: self.chk_auto_y.setChecked(True) # 触发 _on_yrange -> 自动 else: self._set_manual_yrange(self.plot.auto_yrange()) def _set_manual_yrange(self, yr): """切到手动 Y 量程并同步主界面控件(阻断信号防回环)""" self.chk_auto_y.blockSignals(True) self.chk_auto_y.setChecked(False) self.chk_auto_y.blockSignals(False) for s, v in ((self.spin_ylo, yr[0]), (self.spin_yhi, yr[1])): s.blockSignals(True) s.setValue(v) s.blockSignals(False) self.spin_ylo.setEnabled(True) self.spin_yhi.setEnabled(True) self.plot.set_yrange(yr) def _on_plot_yrange(self, yr): """图上拖动改 Y 量程 -> 同步主界面 Ymin/Ymax 输入框""" self._set_manual_yrange(yr) def _go_live(self): self.plot.set_offset(0) self._sync_time_slider() def _on_span_change(self, *_): idx = self.combo_span.currentIndex() if idx < 0: # 滚轮缩放产生的自定义窗宽 return span = PLOT_SPANS[idx][1] self.plot.set_view(span) if span is None: # 跟随模式无滞后可言 self.plot.set_offset(0) self.slider_time.setEnabled(span is not None) self._sync_time_slider() def _on_time_slider(self, value): _tmin, _tmax, maxpan = self.plot.view_range() if maxpan <= 1e-6: return self.plot.set_offset((1000 - value) / 1000 * maxpan) def _sync_time_slider(self): """滚轮/回看/实时后同步 时间窗下拉与滑条 位置(用户拖动中不打扰)""" if self._plot_win is None: return span = self.plot.view_span() idx = next((i for i, (_l, sec) in enumerate(PLOT_SPANS) if sec is not None and span is not None and abs(sec - span) < 0.5), -1 if span is not None else 0) if self.combo_span.currentIndex() != idx: self.combo_span.blockSignals(True) self.combo_span.setCurrentIndex(idx) self.combo_span.blockSignals(False) self.slider_time.setEnabled(span is not None) if self.slider_time.isSliderDown() or not self.slider_time.isEnabled(): return _tmin, _tmax, maxpan = self.plot.view_range() frac = 1.0 if maxpan <= 1e-6 \ else 1.0 - min(self.plot.view_offset() / maxpan, 1.0) value = int(round(frac * 1000)) if self.slider_time.value() != value: self.slider_time.blockSignals(True) self.slider_time.setValue(value) self.slider_time.blockSignals(False) def _dock_plot(self): """把绘图区放回曲线卡槽位; 弹出窗口不存在时静默。 嵌回时重置为跟随全部——卡片内没有滑条, 避免困在滞后的时间窗里""" if self._plot_win is None: return win, self._plot_win = self._plot_win, None # 先摘引用, 防关窗递归 self.plot.set_frozen(False) # 嵌回恢复渲染 self.plot.set_view(None) self.plot.set_offset(0) self.plot.setParent(None) # 脱离窗口布局再回槽位 self._plot_slot.addWidget(self.plot) self.plot.show() win.close() self.btn_pop.setText('弹出') def _clear_plot(self): self.plot.clear() for ui in self._ch_ui: ui['val'].setText('—') def _on_yrange(self, *_): if self.chk_auto_y.isChecked(): self.plot.set_yrange(None) self.spin_ylo.setEnabled(False) self.spin_yhi.setEnabled(False) else: self.plot.set_yrange((self.spin_ylo.value(), self.spin_yhi.value())) self.spin_ylo.setEnabled(True) self.spin_yhi.setEnabled(True) # --- 接收卡 --- def _build_rx_card(self): card, lay = self._card('接收') self.combo_display = ComboBox() for label in RX_DISPLAY_LABELS: self.combo_display.addItem(label) self.chk_raw = CheckBox('原始帧hex') self.chk_ts = CheckBox('时间戳') self.chk_ts.setChecked(True) btn_clear = PushButton('清屏') btn_clear.clicked.connect(lambda: self.text_rx.clear()) self.btn_save = PushButton('保存日志') self.btn_save.setIcon(FluentIcon.SAVE) self.btn_save.clicked.connect(self._save_log) h = self._row(lay, CaptionLabel('payload显示'), self.combo_display, self.chk_raw, self.chk_ts) h.addStretch(1) h.addWidget(btn_clear) h.addWidget(self.btn_save) self.text_rx = PlainTextEdit() self.text_rx.setReadOnly(True) self.text_rx.setLineWrapMode(QPlainTextEdit.NoWrap) self.text_rx.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont)) self.text_rx.setMinimumHeight(120) self.text_rx.document().setMaximumBlockCount(5000) lay.addWidget(self.text_rx, 1) return card def _save_log(self): path, _ = QFileDialog.getSaveFileName(self, '保存接收日志', 'serial_log.txt', '文本文件 (*.txt)') if not path: return try: with open(path, 'w', encoding='utf-8') as f: f.write(self.text_rx.toPlainText()) self._hint(f'日志已保存: {path}') except OSError as e: self._hint(f'保存失败: {e}') # --- 发送卡 --- def _build_tx_card(self): card, lay = self._card('发送', '自动包装: 输入协议载荷, 多条用 / 分隔依次组帧', side=True) # 板型管理行: 一键切换 / 新建 / 存档 / 表格编辑 / 重载 self.combo_profile = ComboBox() self.combo_profile.currentIndexChanged.connect(self._on_profile) self.combo_profile.blockSignals(True) # 构建期不触发切换(此时 ed_tx 未建) for p in self.profiles: self.combo_profile.addItem(p['name']) self.combo_profile.blockSignals(False) btn_new_profile = PushButton('新建板型') btn_new_profile.setToolTip('按当前协议参数新建板型, 命令预设从空白开始') btn_new_profile.clicked.connect(self._on_new_profile) btn_del_profile = PushButton('删除板型') btn_del_profile.setToolTip('删除当前选中的板型(至少保留一个)') btn_del_profile.clicked.connect(self._on_delete_profile) btn_edit_cfg = PushButton('配置') btn_edit_cfg.setIcon(FluentIcon.EDIT) btn_edit_cfg.setToolTip('表格编辑当前板型的命令预设(名称/命令, +加行 −删行)') btn_edit_cfg.clicked.connect(self._edit_profile_commands) btn_reload_cfg = PushButton('重载') btn_reload_cfg.setIcon(FluentIcon.SYNC) btn_reload_cfg.setToolTip('重新读取 board_profiles.json') btn_reload_cfg.clicked.connect(self._reload_profiles) h = self._row(lay, CaptionLabel('板型'), self.combo_profile, trailing=False) h.setStretch(1, 1) h.addWidget(btn_new_profile) h.addWidget(btn_del_profile) h.addWidget(btn_edit_cfg) h.addWidget(btn_reload_cfg) # 预设行: 选中即直接发送 self.combo_preset = ComboBox() self.btn_fill = PushButton('填入') self.btn_fill.clicked.connect(self._fill_preset) self.btn_quick_send = PushButton('直接发送') self.btn_quick_send.setToolTip('把当前选中的预设命令立即发送(等效 填入+发送)') self.btn_quick_send.clicked.connect(self._send_preset_direct) h = self._row(lay, CaptionLabel('预设'), self.combo_preset, trailing=False) h.setStretch(1, 1) h.addWidget(self.btn_fill) h.addWidget(self.btn_quick_send) # 模式行: 右侧是两个保存类操作 self.combo_tx_mode = ComboBox() for label in TX_MODE_LABELS: self.combo_tx_mode.addItem(label) self.combo_tx_mode.currentIndexChanged.connect(self._on_tx_mode) self.btn_save_profile = PushButton('存为板型') self.btn_save_profile.setToolTip('把当前协议参数与命令预设表保存为一个板型(同名覆盖)') self.btn_save_profile.clicked.connect(self._on_save_profile_as) self.btn_add_cmd = PushButton('存入预设') self.btn_add_cmd.setToolTip('把当前输入框的命令加入当前板型的预设表') self.btn_add_cmd.clicked.connect(self._add_command) h = self._row(lay, CaptionLabel('模式'), self.combo_tx_mode, trailing=False) h.addStretch(1) h.addWidget(self.btn_save_profile) h.addWidget(self.btn_add_cmd) self.ed_tx = LineEdit() self.ed_tx.returnPressed.connect(self._send) self.ed_tx.textChanged.connect(self._update_preview) self.btn_send = PrimaryPushButton('发送') self.btn_send.setIcon(FluentIcon.SEND) self.btn_send.clicked.connect(self._send) self._row(lay, self.ed_tx, self.btn_send, trailing=False) self.chk_periodic = CheckBox('周期发送') self.chk_periodic.toggled.connect(self._on_periodic) self.spin_interval = SpinBox() self.spin_interval.setRange(100, 60000) self.spin_interval.setSingleStep(100) self.spin_interval.setValue(1000) self.spin_interval.setSuffix(' ms') self.spin_interval.valueChanged.connect(self._on_periodic) self.lb_preview = CaptionLabel('') h = self._row(lay, self.chk_periodic, self.spin_interval, self.lb_preview, trailing=False) h.setStretch(2, 1) # 组帧预览吃掉行内剩余宽度 lay.addStretch(1) # 内容贴顶: 富余高度收拢在卡底 return card def _on_tx_mode(self): wrap = TX_MODES[self.combo_tx_mode.currentIndex()] == 'wrap' self.ed_tx.setPlaceholderText( '如 M1 或 M731S100 · 多条用 / 分隔: M1/M731S0T1000' if wrap else '如 D5 01 02 4D 31 81 或 D50102 4D3181(空白分隔可省)') self.combo_preset.setEnabled(wrap) self.btn_fill.setEnabled(wrap) self.btn_quick_send.setEnabled(wrap) self.btn_add_cmd.setEnabled(wrap) self._update_preview() # --- 板型档案 --- def _on_profile(self, idx, apply_proto=True): """切换板型: 应用其协议参数(启动恢复时不重复应用)并装载命令预设表""" if not 0 <= idx < len(self.profiles): return prof = self.profiles[idx] if apply_proto: pr = prof['proto'] try: self.ed_tx_hdr.setText(str(pr['tx_hdr'])) self.ed_rx_hdr.setText(str(pr['rx_hdr'])) self.ed_board.setText(str(pr['board'])) self.ed_layout.setText(str(pr['layout'])) self.combo_len_sem.setCurrentIndex(LEN_SEMS.index(pr['len_sem'])) self.combo_crc.setCurrentIndex(tuple(CRC_ALGOS).index(pr['crc_algo'])) self.combo_scope.setCurrentIndex( tuple(CRC_SCOPE_LABELS).index(pr['crc_scope'])) self._hint(f'已切换板型: {prof["name"]}(协议参数已应用)') except (ValueError, KeyError): self._hint(f'板型 "{prof["name"]}" 协议字段非法, 仅加载命令表') self._commands = [list(c) for c in prof['commands']] self._reload_preset_combo() def _reload_preset_combo(self): self.combo_preset.clear() for name, cmd in self._commands: self.combo_preset.addItem(f'{name} · {cmd}') # 名称与命令同显 self.combo_preset.setCurrentIndex(0 if self._commands else -1) def _fill_preset(self): idx = self.combo_preset.currentIndex() if 0 <= idx < len(self._commands): self.ed_tx.setText(self._commands[idx][1]) def _send_preset_direct(self): """选中预设 -> 立即发送(经输入框, 保留历史与组帧预览一致性)""" idx = self.combo_preset.currentIndex() if not 0 <= idx < len(self._commands): self._hint('未选择预设命令') return self.ed_tx.setText(self._commands[idx][1]) self._send() def _add_command_named(self, name, cmd): """命令加入当前板型预设表(按命令文本去重, 名称可自定义); 返回是否新增""" if any(c[1] == cmd for c in self._commands): self._hint(f'命令 {cmd} 已在预设表中') return False idx = self.combo_profile.currentIndex() if not 0 <= idx < len(self.profiles): self._hint('无有效板型, 无法存入') return False self._commands.append([name, cmd]) self.profiles[idx]['commands'] = [list(c) for c in self._commands] config.save_profiles(self.profiles) self._reload_preset_combo() self.combo_preset.setCurrentIndex(len(self._commands) - 1) self._hint(f'已存入板型 "{self.profiles[idx]["name"]}": {name} · {cmd}') return True def _add_command(self): text = self.ed_tx.text().strip() if not text: self._hint('输入框为空, 无命令可存入') return if TX_MODES[self.combo_tx_mode.currentIndex()] != 'wrap': self._hint('HEX 直发模式下不能存入预设') return name, ok = QInputDialog.getText(self, '存入预设命令', f'显示名称(命令: {text}):', text=text) if not ok: return self._add_command_named(name.strip() or text, text) def _current_proto_dict(self): """当前协议卡参数 -> 档案 proto 字典; 非法返回 None(错误文本在 _proto_err)""" cfg = self._proto_from_ui() if cfg is None: return None return {'tx_hdr': f'{cfg.tx_hdr:02X}', 'rx_hdr': f'{cfg.rx_hdr:02X}', 'board': f'{cfg.board:02X}', 'layout': self.ed_layout.text().strip() or DEFAULT_LAYOUT, 'len_sem': LEN_SEMS[self.combo_len_sem.currentIndex()], 'crc_algo': cfg.crc_algo, 'crc_scope': cfg.crc_scope} def _save_profile_as(self, name): """当前协议参数 + 当前命令预设表 -> 保存为板型(同名覆盖); 返回是否成功""" name = (name or '').strip() if not name: self._hint('板型名称为空, 未保存') return False proto = self._current_proto_dict() if proto is None: self._hint(f'协议参数非法, 未保存: {self._proto_err}') return False entry = {'name': name, 'proto': proto, 'commands': [list(c) for c in self._commands]} idx = next((i for i, p in enumerate(self.profiles) if p['name'] == name), None) if idx is None: self.profiles.append(entry) else: self.profiles[idx] = entry config.save_profiles(self.profiles) idx = self._refresh_profile_combo(name) self._on_profile(idx, apply_proto=False) # 装载该板型命令表 self._hint(f'板型已保存: {name}({len(self._commands)} 条命令)') return True def _on_save_profile_as(self): text, ok = QInputDialog.getText(self, '存为板型', '板型名称:', text=self.combo_profile.currentText()) if ok: self._save_profile_as(text) def _new_profile_named(self, name): """按当前协议参数新建板型, 命令预设从空白开始(不继承其他板型的命令)""" name = (name or '').strip() if not name: self._hint('板型名称为空, 未新建') return False if any(p['name'] == name for p in self.profiles): self._hint(f'板型 "{name}" 已存在, 覆盖请用 存为板型') return False proto = self._current_proto_dict() if proto is None: self._hint(f'协议参数非法, 未新建: {self._proto_err}') return False self.profiles.append({'name': name, 'proto': proto, 'commands': []}) config.save_profiles(self.profiles) idx = self._refresh_profile_combo(name) self._on_profile(idx, apply_proto=False) self._hint(f'已新建板型: {name}(命令预设为空, 用 存入/编辑 添加)') return True def _on_new_profile(self): text, ok = QInputDialog.getText(self, '新建板型', '板型名称:') if ok: self._new_profile_named(text) def _delete_profile(self, name): """删除板型(连同其命令预设); 至少保留一个, 删除后切到第一个板型""" if len(self.profiles) <= 1: self._hint('至少保留一个板型, 不能删除') return False idx = next((i for i, p in enumerate(self.profiles) if p['name'] == name), None) if idx is None: self._hint(f'板型 "{name}" 不存在') return False del self.profiles[idx] config.save_profiles(self.profiles) idx = self._refresh_profile_combo(self.profiles[0]['name']) self._on_profile(idx, apply_proto=False) # 装载新板型命令表, 协议参数不动 self._hint(f'板型已删除: {name}') return True def _on_delete_profile(self): name = self.combo_profile.currentText() if QMessageBox.question( self, '删除板型', f'确定删除板型 "{name}"?\n其命令预设将一并删除。', QMessageBox.Yes | QMessageBox.No) == QMessageBox.Yes: self._delete_profile(name) def _refresh_profile_combo(self, select_name=''): """重建板型下拉(阻断信号, 由调用方决定是否触发切换), 返回选中索引""" self.combo_profile.blockSignals(True) self.combo_profile.clear() for p in self.profiles: self.combo_profile.addItem(p['name']) idx = next((i for i, p in enumerate(self.profiles) if p['name'] == select_name), 0 if self.profiles else -1) self.combo_profile.setCurrentIndex(idx) self.combo_profile.blockSignals(False) return idx def _edit_profile_commands(self): """弹窗表格编辑当前板型的命令预设(名称/命令两列, +加行 −删行)""" idx = self.combo_profile.currentIndex() if not 0 <= idx < len(self.profiles): self._hint('无有效板型, 无法编辑') return dlg = ProfileEditDialog(self.profiles[idx], self) if dlg.exec() != QDialog.Accepted: return cmds = dlg.commands() self.profiles[idx]['commands'] = cmds config.save_profiles(self.profiles) self._commands = [list(c) for c in cmds] self._reload_preset_combo() self._hint(f'板型 "{self.profiles[idx]["name"]}" 命令预设已保存({len(cmds)} 条)') def _reload_profiles(self): cur = self.combo_profile.currentText() self.profiles = config.load_profiles() idx = self._refresh_profile_combo(cur) self._on_profile(idx, apply_proto=False) self._hint(f'板型档案已重载(共 {len(self.profiles)} 个)') def _on_periodic(self, *_): if self.chk_periodic.isChecked(): self._periodic.start(self.spin_interval.value()) else: self._periodic.stop() def _hex_input(self, text): raw = re.sub(r'[^0-9A-Fa-f]', '', text) if not raw or len(raw) % 2: raise ValueError('HEX 直发需要偶数个十六进制字符') return bytes.fromhex(raw) def _update_preview(self, *_): text = self.ed_tx.text().strip() if TX_MODES[self.combo_tx_mode.currentIndex()] == 'hex': try: self.lb_preview.setText(f'→ {hexs(self._hex_input(text))}') except ValueError as e: self.lb_preview.setText(f'⚠ {e}') return cfg = self._proto_from_ui() if cfg is None: self.lb_preview.setText(f'⚠ 协议参数: {self._proto_err}') self.lb_proto.setText(f'⚠ {self._proto_err}') return self.lb_proto.setText( '令牌: HDR=包头 ID=板ID LEN=长度 PAYLOAD=载荷 CRC=校验, ' '十六进制字面量(如 AA 55)为固定同步字节') if not text: self.lb_preview.setText('') return try: frames = [cfg.build_frame(s.strip().encode('ascii')) for s in text.split('/') if s.strip()] except (ValueError, UnicodeEncodeError) as e: self.lb_preview.setText(f'⚠ {e}') return self.lb_preview.setText(' | '.join(hexs(f) for f in frames)) def _send(self): text = self.ed_tx.text().strip() if not text: return if TX_MODES[self.combo_tx_mode.currentIndex()] == 'hex': try: data = self._hex_input(text) except ValueError as e: self._hint(f'发送失败: {e}') return self.link.send_frame(data, label=None) else: cfg = self._proto_from_ui() if cfg is None: self._hint('协议参数非法, 无法组帧') return try: items = [] for seg in text.split('/'): seg = seg.strip() if not seg: raise ValueError('存在空段(检查 / 分隔符)') payload = seg.encode('ascii') if len(payload) > LEN_MAX: raise ValueError(f'"{seg[:8]}…" 超长(>{LEN_MAX} 字节)') items.append((seg, cfg.build_frame(payload))) except (ValueError, UnicodeEncodeError) as e: self._hint(f'发送失败: {e}') return for seg, frame in items: self.link.send_frame(frame, label=seg) if text not in self.tx_history or not self.tx_history \ or self.tx_history[-1] != text: self.tx_history.append(text) if len(self.tx_history) > 100: self.tx_history.pop(0) self._hist_pos = -1 def eventFilter(self, obj, ev): if obj is self._plot_win and ev.type() == QEvent.Close: self._dock_plot() # 关掉曲线窗口 = 嵌回主界面 return True if obj is self.ed_tx and ev.type() == QEvent.KeyPress: if ev.key() == Qt.Key_Up: self._hist_step(-1) return True if ev.key() == Qt.Key_Down: self._hist_step(1) return True return super().eventFilter(obj, ev) def _hist_step(self, d): """↑ 回溯更早的历史, ↓ 退回更新的(越过末尾清空输入框)""" if not self.tx_history: return if d < 0: self._hist_pos = len(self.tx_history) - 1 if self._hist_pos == -1 \ else max(0, self._hist_pos - 1) self.ed_tx.setText(self.tx_history[self._hist_pos]) elif self._hist_pos != -1: self._hist_pos += 1 if self._hist_pos >= len(self.tx_history): self._hist_pos = -1 self.ed_tx.clear() else: self.ed_tx.setText(self.tx_history[self._hist_pos]) # ------------------------------------------------------- 事件与回显 def _drain_events(self): for _ in range(400): # 单次上限, 防极端速率卡 UI try: ev = self.events.get_nowait() except queue.Empty: return kind = ev[0] if kind == 'status': self._on_status(ev[1]) elif kind == 'frame': self._on_frame(ev[1]) elif kind == 'raw': self._on_raw(ev[1]) elif kind == 'tx': self._on_tx(ev[1], ev[2]) def _hint(self, text): self.lb_state.setText(text) self.statusBar().showMessage(text, 8000) self._append_rx(f'· {_timestamp()} {text}') def _on_status(self, text): """连接状态: 同步连接按钮文案 + 提示""" if text.startswith('已连接'): self.btn_conn.setText('断开') elif text.startswith(('已断开', '连接失败', '串口丢失')): self.btn_conn.setText('连接') self._hint(text) def _append_rx(self, line): self.text_rx.appendPlainText(line) def _append_rx_frame(self, prefix, core, suffix=''): """帧日志行: payload 核心内容垫一层浅背景色, 目光一眼锁定。 用半透明 rgba —— 主题无关, 明暗切换后历史行不会残留异色""" self.text_rx.appendHtml( f'{html.escape(prefix)}' f'' f'{html.escape(core)}' f'{html.escape(suffix)}') def _on_frame(self, fr): self._rx_n += 1 self._rx_b += len(fr.raw) ts = f'{_timestamp()} ' if self.chk_ts.isChecked() else '' disp = RX_DISPLAYS[self.combo_display.currentIndex()] payload = hexs(fr.payload) if disp == 'hex' else \ (clean_text(fr.payload.decode('ascii', 'replace')) or '(空)') if fr.code is None: # 布局无 ID 字段 head = f'← {ts}' else: name = TYPE_NAMES.get(fr.code) head = f'← {ts}B=0x{fr.code:02X}' + (f' {name}' if name else '') suffix = f' [{hexs(fr.raw)}]' if self.chk_raw.isChecked() else '' self._append_rx_frame(f'{head} LEN={len(fr.payload)} ', payload, suffix) for i, c in enumerate(self._collect_channels()): if not c['enabled']: continue v = extract_value(fr.payload, c['mode'], offset=c['offset'], fmt=c['fmt'], index=c['index'], pattern=c['pattern'], scale=c['scale']) if v is not None: self._ch_ui[i]['val'].setText(fmt_val(v)) self.plot.add(i, v) self._update_counters() def _on_raw(self, data): self._rx_n += 1 self._rx_b += len(data) ts = f'{_timestamp()} ' if self.chk_ts.isChecked() else '' disp = RX_DISPLAYS[self.combo_display.currentIndex()] body = hexs(data) if disp == 'hex' else clean_text(data.decode('ascii', 'replace')) self._append_rx(f'← {ts}{body}') self._update_counters() def _on_tx(self, label, data): self._tx_n += 1 self._tx_b += len(data) if label is None: self._append_rx(f'→ {_timestamp()} [HEX] {hexs(data)}') else: self._append_rx(f'→ {_timestamp()} {label} [{hexs(data)}]') self._update_counters() def _update_counters(self): self.statusBar().showMessage( f'发送 {self._tx_n} 帧 / {self._tx_b} 字节 接收 {self._rx_n} 帧 / {self._rx_b} 字节') def _on_theme(self, idx): self.theme_manager.set_mode(('system', 'light', 'dark')[idx]) def show_about(self): QMessageBox.about( self, f'关于 {APP_NAME}', f'{APP_NAME} v{APP_VERSION}\n\n' 'RS485/串口调试助手 · 收发 + 协议解析 + 曲线绘制\n' '帧构成/包头/板ID/LEN口径/CRC 全可定制, 默认兼容 DLPilot 光机协议\n' 'PySide6-Fluent-Widgets + pyserial\n\n' f'{REPO_URL}') # ------------------------------------------------------- 配置进出 def _apply_store_to_ui(self): s, p, t, r = (self.cfg_store['serial'], self.cfg_store['proto'], self.cfg_store['tx'], self.cfg_store['rx']) self.combo_baud.setCurrentText(str(s['baud'])) self.combo_databits.setCurrentIndex( ('8', '7', '6', '5').index(str(s['bytesize']))) self.combo_parity.setCurrentIndex('NEO'.index(s['parity'][0].upper())) self.combo_stop.setCurrentIndex(('1', '2').index(str(s['stopbits']))) self.ed_tx_hdr.setText(p['tx_hdr']) self.ed_rx_hdr.setText(p['rx_hdr']) self.ed_board.setText(p['board']) self.ed_layout.setText(p.get('layout', DEFAULT_LAYOUT)) self.combo_len_sem.setCurrentIndex( LEN_SEMS.index(p.get('len_sem', 'payload'))) self.combo_crc.setCurrentIndex(tuple(CRC_ALGOS).index(p['crc_algo'])) self.combo_scope.setCurrentIndex(tuple(CRC_SCOPE_LABELS).index(p['crc_scope'])) self.chk_parse.setChecked(bool(p['parse'])) self.combo_tx_mode.setCurrentIndex(TX_MODES.index(t['mode'])) self.chk_periodic.setChecked(False) # 周期发送不随配置自动启动 self.spin_interval.setValue(int(t['interval_ms'])) self.combo_display.setCurrentIndex(RX_DISPLAYS.index(r['display'])) self.chk_raw.setChecked(bool(r['show_raw'])) # 板型: 命中记忆的板型则装载其命令表(协议参数以上方 store 为准, 不重复应用) name = t.get('profile', '') idx = next((i for i, p in enumerate(self.profiles) if p['name'] == name), 0 if self.profiles else -1) self.combo_profile.blockSignals(True) self.combo_profile.setCurrentIndex(idx) self.combo_profile.blockSignals(False) self._on_profile(idx, apply_proto=False) for i, c in enumerate(self.cfg_store['channels']): ui = self._ch_ui[i] ui['chk'].setChecked(bool(c['enabled'])) ui['name'].setText(c['name']) ui['mode'].setCurrentIndex(CH_MODES.index(c['mode'])) ui['off'].setValue(int(c['offset'])) ui['fmt'].setCurrentIndex( [k for k, _ in FMT_LABELS].index(c['fmt'])) ui['idx'].setValue(int(c['index'])) ui['pat'].setText(c['pattern']) ui['scale'].setValue(float(c['scale'])) if s['port']: for i, (dev, _label) in enumerate(self._port_devs): if dev == s['port']: self.combo_port.setCurrentIndex(i) break self._apply_proto() def _store_from_ui(self): idx = self.combo_port.currentIndex() port = self._port_devs[idx][0] if 0 <= idx < len(self._port_devs) else '' try: baud = int(self.combo_baud.currentText() or '115200') except ValueError: baud = 115200 proto = self._proto_from_ui() p = self.cfg_store['proto'] if proto is not None: p.update(tx_hdr=f'{proto.tx_hdr:02X}', rx_hdr=f'{proto.rx_hdr:02X}', board=f'{proto.board:02X}', crc_algo=proto.crc_algo, crc_scope=proto.crc_scope, layout=self.ed_layout.text().strip() or DEFAULT_LAYOUT, len_sem=LEN_SEMS[self.combo_len_sem.currentIndex()]) p['parse'] = self.chk_parse.isChecked() return { 'serial': {'port': port, 'baud': baud, 'bytesize': int(self.combo_databits.currentText()), 'parity': self.combo_parity.currentText()[0], 'stopbits': int(self.combo_stop.currentText())}, 'proto': p, 'tx': {'mode': TX_MODES[self.combo_tx_mode.currentIndex()], 'periodic': self.chk_periodic.isChecked(), 'interval_ms': self.spin_interval.value(), 'profile': self.combo_profile.currentText()}, 'rx': {'display': RX_DISPLAYS[self.combo_display.currentIndex()], 'show_raw': self.chk_raw.isChecked()}, 'channels': self._collect_channels(), } def closeEvent(self, ev): self._periodic.stop() self._dock_plot() # 曲线弹出窗口一并收拢, 保证应用退出 self.link.close() config.save(self._store_from_ui()) super().closeEvent(ev) def icon_path(): """应用图标: exe(onefile)在 _MEIPASS/assets, 源码运行在工具根 assets/""" if hasattr(sys, '_MEIPASS'): return os.path.join(sys._MEIPASS, 'assets', 'serial-mate.ico') return os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'assets', 'serial-mate.ico') def main(): ap = argparse.ArgumentParser(description='SerialMate 串口/RS485 调试助手') ap.add_argument('--seconds', type=float, default=0, help='N 秒后自动退出(冒烟测试)') _args = ap.parse_args() app = QApplication(sys.argv) app.setWindowIcon(QIcon(icon_path())) # 主窗/弹窗/任务栏统一图标 win = Window(seconds=_args.seconds) win.show() sys.exit(app.exec()) if __name__ == '__main__': main()