1459 lines
63 KiB
Python
1459 lines
63 KiB
Python
#!/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'<span style="background-color:rgba(96,125,180,0.16);">'
|
||
f'{html.escape(core)}</span>'
|
||
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()
|