Files
network-debugger/network_debugger.py

2486 lines
90 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
网络调试助手
支持:串口、TCP(客户端/服务器)、UDP(客户端/服务器)
功能:收发数据(文本/十六进制)、定时发送、时间戳、清空日志
"""
import sys
import os
import json
import time
import struct
import copy
import threading
import socket
from datetime import datetime
import serial
import serial.tools.list_ports
from PySide6.QtCore import Qt, QTimer, Signal, QSize, QRect, QPoint, QEvent
from PySide6.QtGui import QFont, QColor, QTextCharFormat, QTextCursor, QIcon, QPalette
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout, QHBoxLayout,
QGridLayout, QLabel, QComboBox, QLineEdit, QPushButton, QDialog,
QPlainTextEdit, QCheckBox, QSpinBox, QSplitter, QMessageBox,
QTableWidget, QTableWidgetItem, QListWidget, QListWidgetItem,
QGroupBox, QRadioButton, QHeaderView, QFileDialog, QWidgetItem, QLayout,
QToolBar, QMenu, QInputDialog, QSystemTrayIcon,
)
STAMP_OPTS = ['', 'ASCII(YYYY-MM-DD HH:MM:SS)', 'ASCII(HH:MM:SS)',
'Unix秒(4字节小端)', 'Unix秒(4字节大端)',
'Unix毫秒(8字节小端)', 'Unix毫秒(8字节大端)', 'BCD(YYMMDDHHMMSS)']
CRC_OPTS = ['', 'CRC16-MODBUS', 'CRC16-CCITT(XModem)', 'CRC16-IBM',
'CRC32', 'SUM8', 'XOR8']
FIELD_TYPES = ['固定字节', '时间戳', '长度字段', 'CRC校验', '序号', '接收数据']
TYPE_TO_NAME = {'fix': '固定字节', 'stamp': '时间戳', 'len': '长度字段', 'crc': 'CRC校验', 'seq': '序号', 'recv': '接收数据'}
TYPE_FROM_NAME = {v: k for k, v in TYPE_TO_NAME.items()}
WIDTH_OPTS = ['1', '2', '4']
ENDIAN_OPTS = ['小端', '大端']
def inherit_topmost(dialog):
"""若主窗口处于置顶状态,则让弹出的对话框也置顶,避免被主窗口遮住"""
p = dialog.parentWidget()
while p is not None:
if getattr(p, '_topmost_active', False):
dialog.setWindowFlag(Qt.WindowStaysOnTopHint, True)
return
p = p.parentWidget()
class FlowLayout(QLayout):
"""可自动换行的流式布局:窗口过窄时控件自动换到下一行,不会显示不全"""
def __init__(self, parent=None, h_spacing=8, v_spacing=6):
super().__init__(parent)
if parent is not None:
self.setContentsMargins(0, 0, 0, 0)
self._h_spacing = h_spacing
self._v_spacing = v_spacing
self._items = []
def addItem(self, item):
self._items.append(item)
def addWidget(self, widget):
self._items.append(QWidgetItem(widget))
def count(self):
return len(self._items)
def itemAt(self, index):
return self._items[index] if 0 <= index < len(self._items) else None
def takeAt(self, index):
if 0 <= index < len(self._items):
return self._items.pop(index)
return None
def expandingDirections(self):
return Qt.Orientations(Qt.Orientation(0))
def hasHeightForWidth(self):
return True
def heightForWidth(self, width):
return self._do_layout(QRect(0, 0, width, 0), True)
def setGeometry(self, rect):
super().setGeometry(rect)
self._do_layout(rect, False)
def sizeHint(self):
return self.minimumSize()
def minimumSize(self):
size = QSize()
for item in self._items:
size = size.expandedTo(item.minimumSize())
m = self.contentsMargins()
size += QSize(m.left() + m.right(), m.top() + m.bottom())
return size
def _smart_spacing(self, item):
return self._h_spacing
def _do_layout(self, rect, test_only):
m = self.contentsMargins()
effective = QRect(rect.x() + m.left(), rect.y() + m.top(),
rect.width() - m.left() - m.right(),
rect.height() - m.top() - m.bottom())
x, y = effective.x(), effective.y()
line_height = 0
for item in self._items:
hint = item.sizeHint()
if x + hint.width() > effective.right() and line_height > 0:
x = effective.x()
y += line_height + self._v_spacing
line_height = 0
if not test_only:
item.setGeometry(QRect(QPoint(x, y), hint))
x += hint.width() + self._h_spacing
line_height = max(line_height, hint.height())
return y + line_height - effective.y() + m.top() + m.bottom()
class SendEdit(QPlainTextEdit):
"""回车即发送的发送框,支持↑↓键浏览发送历史,Ctrl+滚轮调整字号"""
enterPressed = Signal()
histNav = Signal(int)
def __init__(self, parent=None):
super().__init__(parent)
self._font_size = 11
def wheelEvent(self, event):
if event.modifiers() & Qt.ControlModifier:
delta = event.angleDelta().y()
if delta:
self._font_size = max(8, min(40, self._font_size + (1 if delta > 0 else -1)))
font = self.font()
font.setPointSize(self._font_size)
self.setFont(font)
event.accept()
return
super().wheelEvent(event)
def font_size(self):
return self._font_size
def keyPressEvent(self, e):
if e.key() in (Qt.Key_Return, Qt.Key_Enter) and not (e.modifiers() & Qt.ShiftModifier):
self.enterPressed.emit()
return
if e.key() in (Qt.Key_Up, Qt.Key_Down) and not (e.modifiers() & Qt.ShiftModifier) \
and self.document().blockCount() == 1:
self.histNav.emit(-1 if e.key() == Qt.Key_Up else 1)
return
super().keyPressEvent(e)
class RecvEdit(QPlainTextEdit):
"""接收区编辑框:Ctrl+滚轮 调整字体大小,右键菜单"""
MIN_FONT = 8
MAX_FONT = 40
DEFAULT_FONT = 11
saveRequested = Signal()
clearRequested = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self._font_size = self.DEFAULT_FONT
def set_font_size(self, size):
self._font_size = max(self.MIN_FONT, min(self.MAX_FONT, int(size)))
font = self.font()
font.setPointSize(self._font_size)
self.setFont(font)
def font_size(self):
return self._font_size
def contextMenuEvent(self, event):
menu = self.createStandardContextMenu()
menu.addSeparator()
act_save = menu.addAction('另存为…')
act_clear = menu.addAction('清空')
ch = menu.exec(event.globalPos())
if ch == act_save:
self.saveRequested.emit()
elif ch == act_clear:
self.clearRequested.emit()
def wheelEvent(self, event):
if event.modifiers() & Qt.ControlModifier:
delta = event.angleDelta().y()
if delta:
self.set_font_size(self.font_size() + (1 if delta > 0 else -1))
event.accept()
return
super().wheelEvent(event)
class NoWheelSpinBox(QSpinBox):
def wheelEvent(self, event):
event.ignore()
class NoWheelComboBox(QComboBox):
def wheelEvent(self, event):
event.ignore()
def pack_int(val: int, width: int, endian: str) -> bytes:
width = int(width)
if width == 1:
return bytes([val & 0xFF])
prefix = '<' if endian == '小端' else '>'
return struct.pack(prefix + ('H' if width == 2 else 'I'), val & 0xFFFF if width == 2 else val & 0xFFFFFFFF)
def parse_match(hex_text: str):
"""解析匹配模板,?? 表示任意字节;返回 None 列表为通配"""
s = ''.join(hex_text.split())
s = s.replace(',', '').replace(':', '')
if len(s) % 2 != 0:
raise ValueError('匹配模板十六进制字符个数必须为偶数')
out = []
for i in range(0, len(s), 2):
pair = s[i:i + 2].upper()
if pair == '??':
out.append(None)
else:
out.append(int(pair, 16))
return out
def find_match(hex_text: str, data: bytes) -> int:
"""子串匹配:返回模板在 data 中的起始下标,未找到返回 -1。?? 为任意字节"""
try:
tmpl = parse_match(hex_text)
except ValueError:
return -1
n = len(tmpl)
if n == 0 or len(data) < n:
return -1
for i in range(len(data) - n + 1):
ok = True
for j, t in enumerate(tmpl):
if t is not None and t != data[i + j]:
ok = False
break
if ok:
return i
return -1
def match_rule(hex_text: str, data: bytes) -> bool:
"""子串匹配:接收数据中任一部分与模板一致即命中,?? 为任意字节"""
return find_match(hex_text, data) != -1
APP_NAME = "网络调试助手"
def app_config_path():
"""返回自动保存配置的文件路径(与主程序同目录)"""
if getattr(sys, 'frozen', False):
base = os.path.dirname(sys.executable)
else:
base = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base, 'app_config.json')
BAUDS = ['1200', '2400', '4800', '9600', '19200', '38400', '57600', '115200']
PARITIES = {'无校验': serial.PARITY_NONE, '奇校验': serial.PARITY_ODD,
'偶校验': serial.PARITY_EVEN}
STOPBITS = {'1': serial.STOPBITS_ONE, '1.5': serial.STOPBITS_ONE_POINT_FIVE,
'2': serial.STOPBITS_TWO}
def ts():
return datetime.now().strftime('%H:%M:%S.%f')[:-3]
def bytes_to_hex(data: bytes) -> str:
return ' '.join(f'{b:02X}' for b in data)
def hex_to_bytes(text: str) -> bytes:
s = ''.join(text.split())
s = s.replace(',', '').replace(':', '')
if len(s) % 2 != 0:
raise ValueError('十六进制字符个数必须为偶数')
return bytes.fromhex(s)
def get_local_ip() -> str:
"""返回本机有效的非环回 IPv4 地址"""
addrs = []
try:
addrs = socket.gethostbyname_ex(socket.gethostname())[2]
except Exception:
pass
if not addrs:
try:
addrs = [info[4][0] for info in socket.getaddrinfo(
socket.gethostname(), None) if info[0] == socket.AF_INET]
except Exception:
addrs = []
for a in addrs:
if a and not a.startswith('127.'):
return a
if addrs:
return addrs[0]
return '127.0.0.1'
def make_stamp(opt: str) -> bytes:
now = datetime.now()
if opt == 'ASCII(YYYY-MM-DD HH:MM:SS)':
return now.strftime('%Y-%m-%d %H:%M:%S').encode('ascii')
if opt == 'ASCII(HH:MM:SS)':
return now.strftime('%H:%M:%S').encode('ascii')
if opt.startswith('Unix秒'):
fmt = '<I' if '小端' in opt else '>I'
return struct.pack(fmt, int(now.timestamp()))
if opt.startswith('Unix毫秒'):
fmt = '<Q' if '小端' in opt else '>Q'
return struct.pack(fmt, int(now.timestamp() * 1000))
if opt == 'BCD(YYMMDDHHMMSS)':
s = now.strftime('%y%m%d%H%M%S')
return bytes(int(s[i:i + 2]) for i in range(0, len(s), 2))
return b''
def _reflect16(x: int, bits: int = 8) -> int:
r = 0
for _ in range(bits):
r = (r << 1) | (x & 1)
x >>= 1
return r
def _crc16(data: bytes, poly: int, init: int, refin: bool, refout: bool, xorout: int) -> int:
crc = init
for b in data:
if refin:
b = _reflect16(b, 8)
crc ^= b << 8
for _ in range(8):
crc = ((crc << 1) ^ poly) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
if refout:
crc = _reflect16(crc, 16)
return (crc ^ xorout) & 0xFFFF
def _crc32(data: bytes) -> int:
crc = 0xFFFFFFFF
for b in data:
crc ^= b
for _ in range(8):
crc = (crc >> 1) ^ 0xEDB88320 if crc & 1 else crc >> 1
return crc ^ 0xFFFFFFFF
def make_crc(opt: str, data: bytes, endian: str = '小端') -> bytes:
fmt = '<' if endian == '小端' else '>'
if opt == 'CRC16-MODBUS':
return struct.pack(fmt + 'H', _crc16(data, 0x8005, 0xFFFF, True, True, 0))
if opt == 'CRC16-CCITT(XModem)':
return struct.pack(fmt + 'H', _crc16(data, 0x1021, 0x0000, False, False, 0))
if opt == 'CRC16-IBM':
return struct.pack(fmt + 'H', _crc16(data, 0x8005, 0x0000, True, True, 0))
if opt == 'CRC32':
return struct.pack(fmt + 'I', _crc32(data))
if opt == 'SUM8':
return bytes([sum(data) & 0xFF])
if opt == 'XOR8':
x = 0
for b in data:
x ^= b
return bytes([x])
return b''
class CrcParam(QWidget):
def __init__(self):
super().__init__()
h = QHBoxLayout(self)
h.setContentsMargins(0, 0, 0, 0)
self.algo = NoWheelComboBox()
self.algo.addItems(CRC_OPTS[1:])
self.endian = NoWheelComboBox()
self.endian.addItems(ENDIAN_OPTS)
self.algo.currentIndexChanged.connect(self._tog)
h.addWidget(self.algo)
h.addWidget(QLabel('字节序'))
h.addWidget(self.endian)
self._tog()
def _tog(self):
self.endian.setEnabled(self.algo.currentText() in
('CRC16-MODBUS', 'CRC16-CCITT(XModem)',
'CRC16-IBM', 'CRC32'))
def value(self):
return {'algo': self.algo.currentText(), 'endian': self.endian.currentText()}
class RecvParam(QWidget):
def __init__(self):
super().__init__()
h = QHBoxLayout(self)
h.setContentsMargins(0, 0, 0, 0)
self.rng = NoWheelComboBox()
self.rng.addItems(['全部', '指定偏移'])
self.offset = NoWheelSpinBox()
self.offset.setRange(0, 65535)
self.length = NoWheelSpinBox()
self.length.setRange(0, 65535)
self.length.setSpecialValueText('到末尾')
self.xform = NoWheelComboBox()
self.xform.addItems(['原样', '反转', '每2字节交换'])
self.rng.currentIndexChanged.connect(self._tog)
for lbl, wid in (('范围', self.rng), ('偏移', self.offset),
('长度', self.length), ('变换', self.xform)):
h.addWidget(QLabel(lbl))
h.addWidget(wid)
self._tog()
def _tog(self):
part = self.rng.currentText() == '指定偏移'
self.offset.setEnabled(part)
self.length.setEnabled(part)
def value(self):
return {'range': self.rng.currentText(), 'offset': self.offset.value(),
'length': self.length.value(), 'xform': self.xform.currentText()}
class LenParam(QWidget):
def __init__(self):
super().__init__()
h = QHBoxLayout(self)
h.setContentsMargins(0, 0, 0, 0)
self.src = NoWheelComboBox()
self.src.addItems(['接收数据', '应答已组数据'])
self.width = NoWheelComboBox()
self.width.addItems(WIDTH_OPTS)
self.endian = NoWheelComboBox()
self.endian.addItems(ENDIAN_OPTS)
h.addWidget(QLabel('长度对象'))
h.addWidget(self.src)
h.addWidget(QLabel('宽度'))
h.addWidget(self.width)
h.addWidget(QLabel('字节序'))
h.addWidget(self.endian)
def value(self):
return {'src': self.src.currentText(), 'width': int(self.width.currentText()),
'endian': self.endian.currentText()}
class SeqParam(QWidget):
def __init__(self):
super().__init__()
h = QHBoxLayout(self)
h.setContentsMargins(0, 0, 0, 0)
self.width = NoWheelComboBox()
self.width.addItems(WIDTH_OPTS)
self.start = NoWheelSpinBox()
self.start.setRange(0, 0x7FFFFFFF)
self.step = NoWheelSpinBox()
self.step.setRange(0, 0x7FFFFFFF)
self.step.setValue(1)
self.endian = NoWheelComboBox()
self.endian.addItems(ENDIAN_OPTS)
h.addWidget(QLabel('宽度'))
h.addWidget(self.width)
h.addWidget(QLabel('起始'))
h.addWidget(self.start)
h.addWidget(QLabel('步长'))
h.addWidget(self.step)
h.addWidget(QLabel('字节序'))
h.addWidget(self.endian)
def value(self):
return {'width': int(self.width.currentText()), 'start': self.start.value(),
'step': self.step.value(), 'endian': self.endian.currentText()}
def make_field_widget(ftype):
if ftype == '固定字节':
e = QLineEdit()
e.setPlaceholderText('HEX,如 AA 01 0F')
return e
if ftype == '时间戳':
c = NoWheelComboBox()
c.addItems(STAMP_OPTS[1:])
return c
if ftype == '长度字段':
return LenParam()
if ftype == 'CRC校验':
return CrcParam()
if ftype == '序号':
return SeqParam()
if ftype == '接收数据':
return RecvParam()
return QWidget()
def read_field(ftype, widget):
if ftype == '固定字节':
return {'type': 'fix', 'hex': widget.text().strip()}
if ftype == '时间戳':
return {'type': 'stamp', 'format': widget.currentText()}
if ftype == '长度字段':
v = widget.value()
return {'type': 'len', **v}
if ftype == 'CRC校验':
if isinstance(widget, CrcParam):
return {'type': 'crc', **widget.value()}
return {'type': 'crc', 'algo': widget.currentText(), 'endian': '小端'}
if ftype == '序号':
v = widget.value()
return {'type': 'seq', **v}
if ftype == '接收数据':
v = widget.value()
return {'type': 'recv', **v}
return {}
def write_field(ftype, widget, field):
if ftype == '固定字节':
widget.setText(field.get('hex', ''))
elif ftype == '时间戳':
widget.setCurrentText(field.get('format', ''))
elif ftype == '长度字段' and isinstance(widget, LenParam):
widget.src.setCurrentText(field.get('src', '接收数据'))
widget.width.setCurrentIndex(WIDTH_OPTS.index(str(field.get('width', 1))))
widget.endian.setCurrentText(field.get('endian', '小端'))
elif ftype == 'CRC校验' and isinstance(widget, CrcParam):
widget.algo.setCurrentText(field.get('algo', ''))
widget.endian.setCurrentText(field.get('endian', '小端'))
elif ftype == '序号' and isinstance(widget, SeqParam):
widget.width.setCurrentIndex(WIDTH_OPTS.index(str(field.get('width', 2))))
widget.start.setValue(field.get('start', 0))
widget.step.setValue(field.get('step', 1))
widget.endian.setCurrentText(field.get('endian', '小端'))
elif ftype == '接收数据' and isinstance(widget, RecvParam):
widget.rng.setCurrentText(field.get('range', '全部'))
widget.offset.setValue(field.get('offset', 0))
widget.length.setValue(field.get('length', 0))
widget.xform.setCurrentText(field.get('xform', '原样'))
class RuleEditDialog(QDialog):
def __init__(self, parent=None, rule=None):
super().__init__(parent)
inherit_topmost(self)
self.setWindowTitle('应答规则')
self.setMinimumWidth(800)
v = QVBoxLayout(self)
row = QHBoxLayout()
row.addWidget(QLabel('应答延时'))
self.spin_delay = NoWheelSpinBox()
self.spin_delay.setRange(0, 600000)
self.spin_delay.setValue(0)
self.spin_delay.setSuffix(' ms')
self.spin_delay.setToolTip('本规则命中后的应答延时,0 表示立即应答')
row.addWidget(self.spin_delay)
row.addSpacing(12)
row.addWidget(QLabel('备注'))
self.edit_note = QLineEdit()
self.edit_note.setPlaceholderText('可留空')
row.addWidget(self.edit_note, 1)
v.addLayout(row)
v.addWidget(QLabel('指令匹配模板(HEX,?? 任意字节,帧中含相同片段即匹配):'))
self.edit_match = QPlainTextEdit()
self.edit_match.setMaximumHeight(80)
self.edit_match.setStyleSheet('QPlainTextEdit{font-family:Consolas}')
v.addWidget(self.edit_match)
v.addWidget(QLabel('指令应答模板(按字节配置)'))
self.table = QTableWidget(0, 3)
self.table.setHorizontalHeaderLabels(['字段类型', '参数', '操作'])
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
header.setSectionResizeMode(1, QHeaderView.Stretch)
self.table.setColumnWidth(1, 460)
self.table.verticalHeader().setVisible(False)
self.table.setSelectionBehavior(QTableWidget.SelectRows)
self.table.setSelectionMode(QTableWidget.SingleSelection)
v.addWidget(self.table)
add_row = QHBoxLayout()
btn_add = QPushButton('添加字段')
btn_add.clicked.connect(lambda: self.add_row('固定字节', {}))
add_row.addWidget(btn_add)
btn_up = QPushButton('↑ 上移')
btn_dn = QPushButton('↓ 下移')
btn_up.setToolTip('上移选中的字段')
btn_dn.setToolTip('下移选中的字段')
btn_up.clicked.connect(lambda: self.move_field(self.table.currentRow(), -1))
btn_dn.clicked.connect(lambda: self.move_field(self.table.currentRow(), 1))
add_row.addWidget(btn_up)
add_row.addWidget(btn_dn)
add_row.addWidget(QLabel('组包顺序即字节顺序,CRC 自动计算前面已组数据'))
add_row.addStretch()
v.addLayout(add_row)
btns = QHBoxLayout()
btns.addStretch()
btn_ok = QPushButton('确定')
btn_cancel = QPushButton('取消')
btn_ok.clicked.connect(self.on_ok)
btn_cancel.clicked.connect(self.reject)
btns.addWidget(btn_ok)
btns.addWidget(btn_cancel)
v.addLayout(btns)
if rule:
self.spin_delay.setValue(rule.get('delay', 0))
self.edit_note.setText(rule.get('note', ''))
self.edit_match.setPlainText(rule.get('match', ''))
for f in rule.get('fields', []):
self.add_row(TYPE_TO_NAME.get(f['type'], '固定字节'), f)
else:
self.spin_delay.setValue(0)
self.add_row('固定字节', {'type': 'fix', 'hex': ''})
def add_row(self, ftype, field):
r = self.table.rowCount()
self.table.insertRow(r)
self.table.setItem(r, 0, QTableWidgetItem())
type_combo = NoWheelComboBox()
type_combo.addItems(FIELD_TYPES)
type_combo.setCurrentText(ftype)
self.table.setCellWidget(r, 0, type_combo)
param = make_field_widget(ftype)
self.table.setCellWidget(r, 1, param)
if field:
write_field(ftype, param, field)
def on_type(idx):
newparam = make_field_widget(type_combo.currentText())
self.table.setCellWidget(r, 1, newparam)
type_combo.currentIndexChanged.connect(on_type)
ops = QWidget()
ho = QHBoxLayout(ops)
ho.setContentsMargins(2, 0, 2, 0)
ho.setSpacing(2)
btn_del = QPushButton('删除')
btn_del.setToolTip('删除此字段')
btn_del.clicked.connect(lambda _, rr=r: self.table.removeRow(rr))
ho.addWidget(btn_del)
self.table.setCellWidget(r, 2, ops)
def move_field(self, rr, delta):
rows = [(self.table.cellWidget(i, 0).currentText(),
read_field(self.table.cellWidget(i, 0).currentText(),
self.table.cellWidget(i, 1)))
for i in range(self.table.rowCount())]
nr = rr + delta
if rr < 0 or not (0 <= nr < len(rows)):
return
rows[rr], rows[nr] = rows[nr], rows[rr]
self.table.setRowCount(0)
for ftype, field in rows:
self.add_row(ftype, field)
self.table.setCurrentCell(nr, 0)
def on_ok(self):
try:
parse_match(self.edit_match.toPlainText())
except ValueError as e:
QMessageBox.warning(self, '格式错误', f'匹配模板错误:\n{e}')
return
fields = []
for r in range(self.table.rowCount()):
ftype = self.table.cellWidget(r, 0).currentText()
field = read_field(ftype, self.table.cellWidget(r, 1))
if ftype == '固定字节' and not field['hex']:
continue
if ftype == '固定字节':
try:
hex_to_bytes(field['hex'])
except ValueError:
QMessageBox.warning(self, '格式错误',
f'固定字节「{field["hex"]}」无效:需为偶数个十六进制字符\n'
f'(表示 00 需输入 00,不能只输 0)')
return
fields.append(field)
if not fields:
QMessageBox.warning(self, '提示', '至少包含一个有效应答字段')
return
self._result = {'note': self.edit_note.text().strip(),
'delay': self.spin_delay.value(),
'match': self.edit_match.toPlainText().strip(), 'fields': fields}
self.accept()
def result(self):
return getattr(self, '_result', None)
class AutoReplyManagerDialog(QDialog):
def __init__(self, rules, parent=None):
super().__init__(parent)
inherit_topmost(self)
self.setWindowTitle('自动应答规则')
self.setMinimumSize(520, 380)
self.rules = [dict(r) for r in rules]
v = QVBoxLayout(self)
self.list_rules = QListWidget()
self.list_rules.itemDoubleClicked.connect(lambda _: self.edit_rule())
v.addWidget(self.list_rules)
self._loading = False
self.list_rules.itemChanged.connect(self._on_checked)
btns = QHBoxLayout()
btn_add = QPushButton('新增')
btn_edit = QPushButton('编辑')
btn_copy = QPushButton('复制')
btn_del = QPushButton('删除')
btn_up = QPushButton('上移')
btn_down = QPushButton('下移')
btn_add.clicked.connect(self.add_rule)
btn_edit.clicked.connect(self.edit_rule)
btn_copy.clicked.connect(self.copy_rule)
btn_del.clicked.connect(self.del_rule)
btn_up.clicked.connect(lambda: self.move_rule(-1))
btn_down.clicked.connect(lambda: self.move_rule(1))
for b in (btn_add, btn_edit, btn_copy, btn_del, btn_up, btn_down):
btns.addWidget(b)
btns.addStretch()
btn_ok = QPushButton('完成')
btn_ok.clicked.connect(self.accept)
btns.addWidget(btn_ok)
v.addLayout(btns)
self._loading = False
self.refresh()
def refresh(self):
self._loading = True
try:
self.list_rules.clear()
for i, r in enumerate(self.rules, 1):
note = r.get('note') or '(无备注)'
item = QListWidgetItem(f'[{i}] 匹配: {r.get("match", "")} {note}')
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Checked if r.get('enabled', True) else Qt.Unchecked)
self.list_rules.addItem(item)
finally:
self._loading = False
def _on_checked(self, item):
if self._loading:
return
idx = self.list_rules.row(item)
if 0 <= idx < len(self.rules):
self.rules[idx]['enabled'] = item.checkState() == Qt.Checked
def add_rule(self):
dlg = RuleEditDialog(self)
if dlg.exec() == QDialog.Accepted and dlg.result():
r = dlg.result()
r['enabled'] = True
self.rules.append(r)
self.refresh()
def edit_rule(self):
row = self.list_rules.currentRow()
if row < 0:
return
enabled = self.rules[row].get('enabled', True)
dlg = RuleEditDialog(self, self.rules[row])
if dlg.exec() == QDialog.Accepted and dlg.result():
r = dlg.result()
r['enabled'] = enabled
self.rules[row] = r
self.refresh()
def copy_rule(self):
row = self.list_rules.currentRow()
if row < 0:
return
self.rules.insert(row + 1, copy.deepcopy(self.rules[row]))
self.refresh()
self.list_rules.setCurrentRow(row + 1)
def del_rule(self):
row = self.list_rules.currentRow()
if row < 0:
return
del self.rules[row]
self.refresh()
def move_rule(self, delta):
row = self.list_rules.currentRow()
new = row + delta
if row < 0 or not (0 <= new < len(self.rules)):
return
self.rules[row], self.rules[new] = self.rules[new], self.rules[row]
self.refresh()
self.list_rules.setCurrentRow(new)
def result(self):
return self.rules
class BatchSendDialog(QDialog):
"""批量发送:每条可单独配置延时,可勾选是否循环"""
batchFinished = Signal()
def __init__(self, panel, parent=None):
super().__init__(parent)
inherit_topmost(self)
self.panel = panel
self.setWindowTitle('批量发送')
self.setMinimumSize(560, 360)
self._stop = False
self._thread = None
self.batchFinished.connect(self._finish)
v = QVBoxLayout(self)
self.table = QTableWidget(0, 4)
self.table.setHorizontalHeaderLabels(['发送', '编号', '延时(ms)', '内容'])
hd = self.table.horizontalHeader()
hd.setSectionResizeMode(0, QHeaderView.ResizeToContents)
hd.setSectionResizeMode(1, QHeaderView.ResizeToContents)
hd.setSectionResizeMode(2, QHeaderView.ResizeToContents)
hd.setSectionResizeMode(3, QHeaderView.Stretch)
self.table.verticalHeader().setVisible(False)
self.table.setSelectionBehavior(QTableWidget.SelectRows)
self.table.setSelectionMode(QTableWidget.SingleSelection)
v.addWidget(self.table)
row = QHBoxLayout()
btn_add = QPushButton('添加')
btn_del = QPushButton('删除所选')
btn_clear = QPushButton('清空')
btn_up = QPushButton('上移')
btn_down = QPushButton('下移')
btn_add.clicked.connect(lambda: self.add_row(True, 0, ''))
btn_del.clicked.connect(self.del_selected)
btn_clear.clicked.connect(self.clear_rows)
btn_up.clicked.connect(lambda: self.move_row(-1))
btn_down.clicked.connect(lambda: self.move_row(1))
row.addWidget(btn_add)
row.addWidget(btn_del)
row.addWidget(btn_clear)
row.addWidget(btn_up)
row.addWidget(btn_down)
row.addStretch()
self.chk_loop = QCheckBox('循环发送')
row.addWidget(self.chk_loop)
self.btn_send = QPushButton('开始')
self.btn_send.clicked.connect(self.toggle_batch)
row.addWidget(self.btn_send)
v.addLayout(row)
self.add_row(True, 0, '')
def add_row(self, enabled, delay, text):
r = self.table.rowCount()
self.table.insertRow(r)
chk = QCheckBox()
chk.setChecked(enabled)
chk.setToolTip('勾选后进入发送队列')
self.table.setCellWidget(r, 0, chk)
self.table.setItem(r, 1, QTableWidgetItem(str(r + 1)))
self.table.item(r, 1).setTextAlignment(Qt.AlignCenter)
spin = NoWheelSpinBox()
spin.setRange(0, 600000)
spin.setValue(delay)
spin.setSuffix(' ms')
self.table.setCellWidget(r, 2, spin)
edit = QLineEdit()
edit.setText(text)
edit.setPlaceholderText('发送内容(按发送区当前 文本/HEX 格式)')
self.table.setCellWidget(r, 3, edit)
return r
def clear_rows(self):
self.table.setRowCount(0)
self._renumber()
def _collect_rows(self):
rows = []
for i in range(self.table.rowCount()):
chk = self.table.cellWidget(i, 0)
spin = self.table.cellWidget(i, 2)
edit = self.table.cellWidget(i, 3)
rows.append((chk.isChecked() if chk else True,
spin.value() if spin else 0,
edit.text() if edit else ''))
return rows
def move_row(self, delta):
rows = self._collect_rows()
r = self.table.currentRow()
nr = r + delta
if r < 0 or not (0 <= nr < len(rows)):
return
rows[r], rows[nr] = rows[nr], rows[r]
self.table.setRowCount(0)
for enabled, delay, text in rows:
self.add_row(enabled, delay, text)
self._renumber()
self.table.setCurrentCell(nr, 0)
def del_selected(self):
rows = sorted({i for i in range(self.table.rowCount())
if self.table.cellWidget(i, 0) and self.table.cellWidget(i, 0).isChecked()},
reverse=True)
for r in rows:
self.table.removeRow(r)
self._renumber()
def get_config(self):
return [{'enabled': chk.isChecked() if (chk := self.table.cellWidget(i, 0)) else True,
'delay': self.table.cellWidget(i, 2).value() if self.table.cellWidget(i, 2) else 0,
'text': self.table.cellWidget(i, 3).text() if self.table.cellWidget(i, 3) else ''}
for i in range(self.table.rowCount())]
def import_config(self, rows):
self.table.setRowCount(0)
for r in rows:
self.add_row(bool(r.get('enabled', True)), int(r.get('delay', 0)), str(r.get('text', '')))
self._renumber()
def _renumber(self):
for i in range(self.table.rowCount()):
item = self.table.item(i, 1)
if item:
item.setText(str(i + 1))
def toggle_batch(self):
if self._thread and self._thread.is_alive():
self._stop = True
self.btn_send.setEnabled(False)
self.btn_send.setText('停止中…')
return
self._stop = False
rows = []
for i in range(self.table.rowCount()):
chk = self.table.cellWidget(i, 0)
if not (chk and chk.isChecked()):
continue
text = self.table.cellWidget(i, 3).text()
if not text:
continue
rows.append((self.table.cellWidget(i, 2).value(), text))
if not rows:
QMessageBox.information(self, '提示', '请至少勾选一条并填写内容')
self.btn_send.setChecked(False)
return
self.btn_send.setText('停止')
self.btn_send.setEnabled(True)
self._set_busy(True)
self._thread = threading.Thread(target=self._run, args=(rows,), daemon=True)
self._thread.start()
def _set_busy(self, busy):
for i in range(self.table.rowCount()):
for c in range(4):
w = self.table.cellWidget(i, c)
if w:
w.setEnabled(not busy)
self.chk_loop.setEnabled(not busy)
def _run(self, rows):
try:
while not self._stop:
for delay, text in rows:
if self._stop:
return
try:
data = self.panel.encode_line(text)
except ValueError as e:
self.panel.info(f'批量发送内容「{text}」无效: {e}')
continue
if not data:
continue
if self.panel.actual_send(data):
self.panel.log_line('TX', data)
if delay:
end = time.time() + delay / 1000.0
while time.time() < end:
if self._stop:
return
time.sleep(0.01)
if not self.chk_loop.isChecked():
break
finally:
self.batchFinished.emit()
def _finish(self):
self._thread = None
self.btn_send.setText('开始')
self.btn_send.setEnabled(True)
self._set_busy(False)
self.panel.info('批量发送结束' if not self._stop else '批量发送已停止')
def closeEvent(self, event):
self._stop = True
super().closeEvent(event)
class BasePanel(QWidget):
"""收发数据通用面板"""
sigLog = Signal(str, object, object)
sigInfo = Signal(str)
sigUi = Signal(object)
def __init__(self, title=''):
super().__init__()
self.sigLog.connect(self._append_log)
self.sigInfo.connect(self._append_info)
self.sigUi.connect(self._exec_ui)
self.timer = QTimer(self)
self._bufs = {}
self.timer.timeout.connect(self.doSend)
self.running = False
self.reply_rules = []
self.batch_rows = []
self.send_history = []
self._hist_idx = 0
self._log_fh = None
self._log_path = ''
self._log_dir = ''
self._log_hour = None
self.log_rotate_timer = QTimer(self)
self.log_rotate_timer.timeout.connect(self._rotate_log)
self._stat_tx = 0
self._stat_rx = 0
self._stat_tx_frames = 0
self._stat_rx_frames = 0
self._filter_enabled = False
self._filter_text = ''
self._append_buffer = []
self._flush_timer = QTimer(self)
self._flush_timer.setSingleShot(True)
self._flush_timer.timeout.connect(self._flush_pending)
self.root = QVBoxLayout(self)
self.top = self.build_top()
self.root.addWidget(self.top)
splitter = QSplitter(Qt.Vertical)
splitter.setChildrenCollapsible(False)
splitter.setHandleWidth(5)
recv_widget = QWidget()
rv = QVBoxLayout(recv_widget)
rv.setContentsMargins(0, 0, 0, 0)
rv.addWidget(QLabel('接收区'))
self.recv = RecvEdit()
self.recv.setReadOnly(True)
self.recv.setMaximumBlockCount(2000)
self.recv.setFont(QFont('Consolas', RecvEdit.DEFAULT_FONT))
self.recv.saveRequested.connect(self.save_received_log)
self.recv.clearRequested.connect(self.recv.clear)
rv.addWidget(self.recv, 1)
splitter.addWidget(recv_widget)
send_widget = QWidget()
sv = QVBoxLayout(send_widget)
sv.setContentsMargins(0, 0, 0, 0)
sv.addWidget(QLabel('发送区'))
self.send = SendEdit()
self.send.setMaximumBlockCount(2000)
self.send.setFont(QFont('Consolas', 11))
self.send.enterPressed.connect(self.on_enter_pressed)
self.send.histNav.connect(self.history_nav)
sv.addWidget(self.send)
splitter.addWidget(send_widget)
splitter.setSizes([320, 160])
splitter.setStretchFactor(0, 3)
splitter.setStretchFactor(1, 1)
self.root.addWidget(splitter, 1)
self.chk_auto = QCheckBox('定时发送')
self.chk_auto.stateChanged.connect(self.toggle_timer)
self.spin_interval = QSpinBox()
self.spin_interval.setRange(10, 100000)
self.spin_interval.setValue(1000)
self.spin_interval.setSuffix(' ms')
self.chk_enter_send = QCheckBox('回车发送')
self.chk_crlf = QCheckBox('回车换行')
self.chk_ts = QCheckBox('时间戳')
self.chk_ts.setChecked(True)
self.chk_reply = QCheckBox('自动应答')
self.chk_reply.toggled.connect(lambda checked: setattr(self, '_reply_enabled', checked))
self.btn_reply = QPushButton('应答配置')
self.btn_reply.clicked.connect(self.open_reply_dialog)
# 接收设置
grp_recv = QGroupBox('接收设置')
hr = QHBoxLayout(grp_recv)
hr.setContentsMargins(8, 4, 8, 4)
self.rb_recv_ascii = QRadioButton('ASCII')
self.rb_recv_hex = QRadioButton('HEX')
self.rb_recv_hex.setChecked(True)
hr.addWidget(QLabel('显示'))
hr.addWidget(self.rb_recv_ascii)
hr.addWidget(self.rb_recv_hex)
hr.addWidget(self.chk_ts)
self.chk_info = QCheckBox('INFO')
self.chk_info.setChecked(True)
hr.addWidget(self.chk_info)
self.chk_filter = QCheckBox('过滤')
self.chk_filter.toggled.connect(self._on_filter_toggled)
self.edit_filter = QLineEdit()
self.edit_filter.setPlaceholderText('关键字')
self.edit_filter.setEnabled(False)
self.edit_filter.setMaximumWidth(130)
self.edit_filter.textChanged.connect(self._on_filter_text)
hr.addWidget(self.chk_filter)
hr.addWidget(self.edit_filter)
self.chk_save_log = QCheckBox('保存日志')
self.chk_save_log.toggled.connect(self.toggle_save_log)
hr.addWidget(self.chk_save_log)
hr.addStretch()
# 发送设置
grp_send = QGroupBox('发送设置')
hs = QHBoxLayout(grp_send)
hs.setContentsMargins(8, 4, 8, 4)
self.rb_send_text = QRadioButton('文本')
self.rb_send_hex = QRadioButton('HEX')
self.rb_send_text.setChecked(True)
hs.addWidget(QLabel('内容'))
hs.addWidget(self.rb_send_text)
hs.addWidget(self.rb_send_hex)
hs.addWidget(self.chk_enter_send)
hs.addWidget(self.chk_crlf)
hs.addWidget(self.chk_auto)
hs.addWidget(self.spin_interval)
hs.addStretch()
# 应答设置
grp_reply = QGroupBox('应答设置')
hp = QHBoxLayout(grp_reply)
hp.setContentsMargins(8, 4, 8, 4)
self.rb_parse_hex = QRadioButton('HEX')
self.rb_parse_ascii = QRadioButton('ASCII')
self.rb_parse_hex.setChecked(True)
parse_box = QWidget()
pb = QHBoxLayout(parse_box)
pb.setContentsMargins(0, 0, 0, 0)
pb.addWidget(self.rb_parse_hex)
pb.addWidget(self.rb_parse_ascii)
self.rb_reply_hex = QRadioButton('HEX')
self.rb_reply_ascii = QRadioButton('ASCII')
self.rb_reply_hex.setChecked(True)
reply_box = QWidget()
rb = QHBoxLayout(reply_box)
rb.setContentsMargins(0, 0, 0, 0)
rb.addWidget(self.rb_reply_hex)
rb.addWidget(self.rb_reply_ascii)
hp.addWidget(self.chk_reply)
hp.addSpacing(4)
hp.addWidget(QLabel('解析'))
hp.addWidget(parse_box)
hp.addSpacing(4)
hp.addWidget(QLabel('回应'))
hp.addWidget(reply_box)
hp.addSpacing(4)
hp.addWidget(self.btn_reply)
hp.addStretch()
opt_flow = FlowLayout()
opt_flow.addWidget(grp_recv)
opt_flow.addWidget(grp_send)
opt_flow.addWidget(grp_reply)
self.root.addLayout(opt_flow)
btn_row = QHBoxLayout()
self.btn_export = QPushButton('导出配置')
self.btn_import = QPushButton('导入配置')
self.btn_export.clicked.connect(self.export_config)
self.btn_import.clicked.connect(self.import_config)
self.btn_send = QPushButton('发送')
self.btn_batch = QPushButton('批量发送')
self.btn_send_file = QPushButton('发送文件')
self.btn_clear_recv = QPushButton('清空接收')
self.btn_clear_send = QPushButton('清空发送')
self.btn_send.clicked.connect(self.doSend)
self.btn_batch.clicked.connect(self.open_batch)
self.btn_send_file.clicked.connect(self.send_file)
self.btn_clear_recv.clicked.connect(self.recv.clear)
self.btn_clear_send.clicked.connect(self.send.clear)
btn_row.addWidget(self.btn_export)
btn_row.addWidget(self.btn_import)
btn_row.addWidget(self.btn_send)
btn_row.addWidget(self.btn_batch)
btn_row.addWidget(self.btn_send_file)
btn_row.addWidget(self.btn_clear_recv)
btn_row.addWidget(self.btn_clear_send)
btn_row.addStretch()
self.stat_label = QLabel('TX: 0 B / 0 帧 RX: 0 B / 0 帧')
btn_row.addWidget(self.stat_label)
self.root.addLayout(btn_row)
self.setWindowTitle(title)
def build_top(self) -> QWidget:
return QWidget()
def on_enter_pressed(self):
if self.chk_enter_send.isChecked():
self.doSend()
def open_batch(self):
dlg = BatchSendDialog(self, parent=self)
if self.batch_rows:
dlg.import_config(self.batch_rows)
dlg.exec()
self.batch_rows = dlg.get_config()
def toggle_timer(self):
if self.chk_auto.isChecked():
self.timer.start(self.spin_interval.value())
else:
self.timer.stop()
def log_line(self, direction, data: bytes, hex_mode: bool = None):
self.sigLog.emit(direction, data, hex_mode)
def run_ui(self, fn):
self.sigUi.emit(fn)
def _exec_ui(self, fn):
fn()
def _append_log(self, direction: str, data, hex_mode: bool = None):
if hex_mode is None:
hex_mode = (self.rb_recv_hex.isChecked() and direction == 'RX') or \
(self.rb_send_hex.isChecked() and direction == 'TX')
if hex_mode:
payload = bytes_to_hex(data)
else:
payload = data.decode('utf-8', 'replace')
if direction == 'TX':
self._stat_tx += len(data)
self._stat_tx_frames += 1
else:
self._stat_rx += len(data)
self._stat_rx_frames += 1
self._update_stats()
if self._filter_enabled and self._filter_text \
and self._filter_text.lower() not in payload.lower():
return
text = f'{ts()}' if self.chk_ts.isChecked() else ''
color = 'blue' if direction == 'TX' else 'green'
self._append_line(f'[{text}] {direction}: {payload}' if text else f'{direction}: {payload}', color)
def _update_stats(self):
self.stat_label.setText(
f'TX: {self._stat_tx} B / {self._stat_tx_frames}'
f'RX: {self._stat_rx} B / {self._stat_rx_frames}')
def _flush_pending(self):
if not self._append_buffer:
return
cur = self.recv.textCursor()
cur.movePosition(QTextCursor.End)
fmt_cache = {}
for text, color in self._append_buffer:
if color not in fmt_cache:
f = QTextCharFormat()
f.setForeground(QColor(color))
fmt_cache[color] = f
cur.setCharFormat(fmt_cache[color])
cur.insertText(text + '\n')
self._append_buffer.clear()
self.recv.setTextCursor(cur)
bar = self.recv.verticalScrollBar()
bar.setValue(bar.maximum())
def _append_colored(self, text: str, color: str):
self._append_buffer.append((text, color))
if not self._flush_timer.isActive():
self._flush_timer.start(120)
def _on_filter_toggled(self, checked: bool):
self._filter_enabled = checked
self.edit_filter.setEnabled(checked)
def _on_filter_text(self, text: str):
self._filter_text = text
def save_received_log(self):
path, _ = QFileDialog.getSaveFileName(self, '保存接收内容', 'recv.txt',
'文本文件 (*.txt);;所有文件 (*)')
if not path:
return
try:
with open(path, 'w', encoding='utf-8') as f:
f.write(self.recv.toPlainText())
self.info(f'接收内容已保存: {path}')
except OSError as e:
self.info(f'保存失败: {e}')
def _append_line(self, text: str, color: str = None):
self._append_colored(text, color)
if self._log_fh or self._log_dir:
self._rotate_log()
if self._log_fh:
try:
self._log_fh.write(text + '\n')
self._log_fh.flush()
except OSError:
pass
def encode_line(self, text: str) -> bytes:
if not text:
return b''
if self.rb_send_hex.isChecked():
return hex_to_bytes(text)
data = text.encode('utf-8', 'replace')
if self.chk_crlf.isChecked():
data += b'\r\n'
return data
def encode_send(self) -> bytes:
return self.encode_line(self.send.toPlainText())
def encode_reply(self, data: bytes) -> bytes:
"""应答输出:回应格式选ASCII时转大写ASCII十六进制文本,HEX时原样二进制"""
if self.rb_reply_ascii.isChecked():
return data.hex().upper().encode('ascii')
return data
def doSend(self):
try:
data = self.encode_send()
except ValueError as e:
QMessageBox.warning(self, '格式错误', str(e))
return
if data:
text = self.send.toPlainText()
if text and (not self.send_history or self.send_history[-1] != text):
self.send_history.append(text)
self._hist_idx = len(self.send_history)
if self.actual_send(data):
self.log_line('TX', data)
def history_nav(self, delta):
if not self.send_history:
return
n = min(max(self._hist_idx + delta, 0), len(self.send_history) - 1)
if n == self._hist_idx:
return
self._hist_idx = n
self.send.setPlainText(self.send_history[n])
c = self.send.textCursor()
c.movePosition(QTextCursor.End)
self.send.setTextCursor(c)
def actual_send(self, data: bytes) -> bool:
raise NotImplementedError
def send_file(self):
path, _ = QFileDialog.getOpenFileName(self, '选择要发送的文件', '')
if not path:
return
try:
with open(path, 'rb') as f:
data = f.read()
except OSError as e:
QMessageBox.warning(self, '错误', f'读取文件失败:\n{e}')
return
if not data:
self.info('文件内容为空,未发送')
return
self._file_total = len(data)
self.info(f'开始发送文件({len(data)} B): {path}')
threading.Thread(target=self._send_file_worker, args=(data,),
daemon=True).start()
def _send_file_worker(self, data: bytes):
sent = 0
for i in range(0, len(data), 4096):
part = data[i:i + 4096]
if not self.actual_send(part):
self.info('发送文件已中止')
return
sent += len(part)
self.log_line('TX', part)
time.sleep(0.01)
self.info(f'文件发送完成,共 {sent} B')
def send_reply(self, reply: bytes, sock=None, addr=None):
raise NotImplementedError
def build_reply_for_rule(self, rule, recv_data: bytes):
out = bytearray()
for f in rule.get('fields', []):
t = f['type']
if t == 'fix':
try:
out += hex_to_bytes(f.get('hex', ''))
except ValueError:
self.info(f'应答字段固定字节无效已忽略: 「{f.get("hex", "")}')
elif t == 'stamp':
out += make_stamp(f.get('format', ''))
elif t == 'len':
val = len(recv_data) if f.get('src') == '接收数据' else len(out)
out += pack_int(val, f.get('width', 1), f.get('endian', '小端'))
elif t == 'crc':
out += make_crc(f.get('algo', ''), bytes(out), f.get('endian', '小端'))
elif t == 'seq':
cur = f.get('cur', f.get('start', 0))
out += pack_int(cur, f.get('width', 2), f.get('endian', '小端'))
f['cur'] = cur + f.get('step', 1)
elif t == 'recv':
if f.get('range') == '指定偏移':
off = f.get('offset', 0)
ln = f.get('length', 0)
part = recv_data[off: off + ln if ln > 0 else None]
else:
part = recv_data
x = f.get('xform', '原样')
if x == '反转':
part = part[::-1]
elif x == '每2字节交换':
part = b''.join(part[i + 1:i + 2] + part[i:i + 1]
for i in range(0, len(part) - 1, 2)) + \
(b'' if len(part) % 2 == 0 else part[-1:])
out += part
return bytes(out)
def pick_rule(self, recv_data: bytes):
for r in self.reply_rules:
if r.get('enabled', True) is False:
continue
try:
if match_rule(r.get('match', ''), recv_data):
return r
except ValueError:
continue
return None
def feed_receive(self, data: bytes, key, sock=None, addr=None):
"""收包缓存拼接:把到达字节累积起来再按优先级匹配,命中则应答"""
self.log_line('RX', data)
if not getattr(self, '_reply_enabled', False):
return
if not self.reply_rules or not any(r.get('enabled', True) for r in self.reply_rules):
self.info('已开启自动应答,但未选中任何应答规则(应答配置内需点“完成”保存,单选选中的规则才参与应答)')
return
buf = self._bufs.get(key, bytearray())
buf += data
if len(buf) > 1_000_000:
del buf[:len(buf) - 1_000_000]
self._bufs[key] = buf
# 应答解析:ASCII时把收到的ASCII十六进制文本解码为二进制再匹配;HEX时原样匹配
raw_b = bytes(buf)
if self.rb_parse_ascii.isChecked():
try:
b = hex_to_bytes(raw_b.decode('ascii', 'ignore'))
except ValueError:
b = b'' # 文本未到齐,继续缓冲等待
else:
b = raw_b
if not b:
return
for r in self.reply_rules:
if r.get('enabled', True) is False:
continue
mtmp = r.get('match', '')
try:
tmpl = parse_match(mtmp)
except ValueError:
self.info(f'应答规则「{r.get("note") or "无备注"}」的匹配模板无法解析(请检查是否为十六进制)')
continue
if len(tmpl) == 0:
continue
st = find_match(mtmp, b)
if st != -1:
n = len(tmpl)
window = bytes(b[st:st + n])
reply = self.build_reply_for_rule(r, window)
if reply:
out = self.encode_reply(reply)
delay = r.get('delay', 0)
self.info(f'应答规则「{r.get("note") or "无备注"}」命中模板,'
f'{"延时" + str(delay) + "ms后" if delay else ""}发送: {bytes_to_hex(reply)}')
if delay > 0:
threading.Timer(delay / 1000.0,
lambda: self.send_reply(out, sock, addr)).start()
else:
self.send_reply(out, sock, addr)
self._bufs.pop(key, None)
return
self.info(f'应答无规则命中:本次收到{len(data)}B,缓冲累积{len(buf)}B(帧未到齐或与模板不匹配)')
def reply_if_enabled(self, recv_data, sock=None, addr=None):
if not getattr(self, '_reply_enabled', False) or not self.reply_rules:
return
rule = self.pick_rule(recv_data)
if rule is None:
return
reply = self.build_reply_for_rule(rule, recv_data)
if reply:
self.send_reply(reply, sock, addr)
def open_reply_dialog(self):
dlg = AutoReplyManagerDialog(self.reply_rules, self)
if dlg.exec() == QDialog.Accepted:
self.reply_rules = dlg.result()
def collect_config(self):
return {
'recv': {
'display': 'HEX' if self.rb_recv_hex.isChecked() else 'ASCII',
'ts': self.chk_ts.isChecked(),
'info': self.chk_info.isChecked(),
'filter': self._filter_enabled,
'filter_text': self._filter_text,
},
'send': {
'format': 'HEX' if self.rb_send_hex.isChecked() else '文本',
'enter_send': self.chk_enter_send.isChecked(),
'crlf': self.chk_crlf.isChecked(),
'auto': self.chk_auto.isChecked(),
'interval': self.spin_interval.value(),
'font_size': self.send.font_size(),
},
'reply': {
'enabled': self.chk_reply.isChecked(),
'parse': 'HEX' if self.rb_parse_hex.isChecked() else 'ASCII',
'respond': 'HEX' if self.rb_reply_hex.isChecked() else 'ASCII',
},
'font_size': self.recv.font_size(),
'send_history': self.send_history[-200:],
'reply_rules': copy.deepcopy(self.reply_rules),
'batch_rows': copy.deepcopy(self.batch_rows),
}
def apply_config(self, cfg):
if not isinstance(cfg, dict):
return []
msg = []
recv = cfg.get('recv')
if isinstance(recv, dict):
display = recv.get('display', 'HEX')
self.rb_recv_hex.setChecked(display == 'HEX')
self.rb_recv_ascii.setChecked(display != 'HEX')
if 'ts' in recv:
self.chk_ts.setChecked(bool(recv['ts']))
if 'info' in recv:
self.chk_info.setChecked(bool(recv['info']))
if 'filter' in recv:
self.chk_filter.setChecked(bool(recv['filter']))
self.edit_filter.setEnabled(bool(recv['filter']))
if 'filter_text' in recv:
self.edit_filter.setText(recv.get('filter_text', ''))
msg.append('接收设置')
send = cfg.get('send')
if isinstance(send, dict):
fmt = send.get('format', '文本')
self.rb_send_hex.setChecked(fmt == 'HEX')
self.rb_send_text.setChecked(fmt != 'HEX')
if 'enter_send' in send:
self.chk_enter_send.setChecked(bool(send['enter_send']))
if 'crlf' in send:
self.chk_crlf.setChecked(bool(send['crlf']))
if 'auto' in send:
self.chk_auto.setChecked(bool(send['auto']))
if 'interval' in send:
self.spin_interval.setValue(int(send['interval']))
if 'font_size' in send:
try:
size = max(8, min(40, int(send['font_size'])))
f = self.send.font()
f.setPointSize(size)
self.send.setFont(f)
if hasattr(self.send, '_font_size'):
self.send._font_size = size
except (TypeError, ValueError):
pass
msg.append('发送设置')
reply = cfg.get('reply')
if isinstance(reply, dict):
if 'enabled' in reply:
self.chk_reply.setChecked(bool(reply['enabled']))
if 'parse' in reply:
self.rb_parse_hex.setChecked(reply['parse'] == 'HEX')
self.rb_parse_ascii.setChecked(reply['parse'] != 'HEX')
if 'respond' in reply:
self.rb_reply_hex.setChecked(reply['respond'] == 'HEX')
self.rb_reply_ascii.setChecked(reply['respond'] != 'HEX')
msg.append('应答设置')
if 'font_size' in cfg:
try:
self.recv.set_font_size(int(cfg['font_size']))
except (TypeError, ValueError):
pass
msg.append('字体大小')
if 'send_history' in cfg and isinstance(cfg['send_history'], list):
self.send_history = [str(x) for x in cfg['send_history']][-200:]
self._hist_idx = len(self.send_history)
msg.append(f'发送历史 {len(self.send_history)}')
rr = cfg.get('reply_rules')
if isinstance(rr, list) and all(isinstance(x, dict) for x in rr):
for r in rr:
r.setdefault('enabled', True)
self.reply_rules = rr
msg.append(f'应答配置 {len(rr)}')
br = cfg.get('batch_rows')
if isinstance(br, list) and all(isinstance(x, dict) for x in br):
self.batch_rows = [{'enabled': bool(x.get('enabled', True)),
'delay': int(x.get('delay', 0)),
'text': str(x.get('text', ''))} for x in br]
msg.append(f'批量发送 {len(br)}')
return msg
def export_config(self):
data = self.collect_config()
default = f'config_{datetime.now():%Y%m%d_%H%M%S}.json'
path, _ = QFileDialog.getSaveFileName(self, '导出配置', default,
'JSON 文件 (*.json);;所有文件 (*)')
if not path:
return
try:
with open(path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
self.info(f'配置已导出: {path}')
except OSError as e:
self.info(f'导出失败: {e}')
def import_config(self):
path, _ = QFileDialog.getOpenFileName(self, '导入配置', '',
'JSON 文件 (*.json);;所有文件 (*)')
if not path:
return
try:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
except Exception as e:
QMessageBox.warning(self, '导入失败', f'无法读取配置文件:\n{e}')
return
if not isinstance(data, dict):
QMessageBox.warning(self, '导入失败', '配置文件格式无效')
return
msg = self.apply_config(data)
if not msg:
QMessageBox.warning(self, '导入失败', '配置文件中没有可导入的数据')
return
self.info('配置已导入: ' + ''.join(msg))
def toggle_save_log(self, checked: bool):
if checked:
d = QFileDialog.getExistingDirectory(self, '选择日志保存目录', '')
if not d:
self.chk_save_log.setChecked(False)
return
self._log_dir = d
self._log_fh = None
self._log_hour = None
self._rotate_log()
if self._log_fh:
self.log_rotate_timer.start(60000)
self.info(f'日志自动保存已开启 -> {d}(每小时一个文件,保留一个月循环覆盖)')
else:
self.chk_save_log.setChecked(False)
else:
self.log_rotate_timer.stop()
self._close_log()
if self._log_dir:
self.info('日志自动保存已停止')
self._log_dir = ''
def _log_path_for(self, dt) -> str:
return os.path.join(self._log_dir, f'log_{dt:%Y%m%d_%H}.txt')
def _close_log(self):
if self._log_fh:
try:
self._log_fh.close()
except OSError:
pass
self._log_fh = None
self._log_hour = None
def _rotate_log(self):
if not self._log_dir:
return
now = datetime.now()
key = now.strftime('%Y%m%d%H')
if self._log_fh and self._log_hour == key:
return
self._close_log()
try:
self._log_fh = open(self._log_path_for(now), 'a', encoding='utf-8')
self._log_hour = key
except OSError as e:
self.info(f'无法创建日志文件: {e}')
self._log_fh = None
self._cleanup_old_logs()
def _cleanup_old_logs(self):
try:
files = [f for f in os.listdir(self._log_dir)
if f.startswith('log_') and f.endswith('.txt')]
files.sort()
while len(files) > 720:
old = files.pop(0)
try:
os.remove(os.path.join(self._log_dir, old))
except OSError:
pass
except OSError:
pass
def info(self, text: str):
self.sigInfo.emit(text)
def _append_info(self, text: str):
if not self.chk_info.isChecked():
return
text = f'[{ts()}] INFO: {text}' if self.chk_ts.isChecked() else f'INFO: {text}'
self._append_line(text, 'gray')
class SerialPanel(BasePanel):
def __init__(self):
super().__init__('串口')
self.ser = None
self.reader = None
def build_top(self) -> QWidget:
w = QWidget()
g = QGridLayout(w)
self.combox_port = QComboBox()
self.combox_baud = QComboBox()
self.combox_baud.addItems(BAUDS)
self.combox_baud.setCurrentText('115200')
self.combox_data = QComboBox()
self.combox_data.addItems(['5', '6', '7', '8'])
self.combox_data.setCurrentText('8')
self.combox_parity = QComboBox()
self.combox_parity.addItems(list(PARITIES.keys()))
self.combox_stop = QComboBox()
self.combox_stop.addItems(list(STOPBITS.keys()))
self.combox_stop.setCurrentText('1')
self.btn_refresh = QPushButton('刷新')
self.btn_refresh.clicked.connect(self.refresh_ports)
self.btn_open = QPushButton('打开串口')
self.btn_open.clicked.connect(self.toggle_serial)
self.chk_rts = QCheckBox('RTS')
self.chk_dtr = QCheckBox('DTR')
self.chk_rts.setToolTip('打开串口后控制 RTS 引脚电平')
self.chk_dtr.setToolTip('打开串口后控制 DTR 引脚电平')
g.addWidget(QLabel('串口'), 0, 0)
g.addWidget(self.combox_port, 0, 1)
g.addWidget(self.btn_refresh, 0, 2)
g.addWidget(QLabel('波特率'), 0, 3)
g.addWidget(self.combox_baud, 0, 4)
g.addWidget(QLabel('数据位'), 1, 0)
g.addWidget(self.combox_data, 1, 1)
g.addWidget(QLabel('校验位'), 1, 3)
g.addWidget(self.combox_parity, 1, 4)
g.addWidget(QLabel('停止位'), 2, 0)
g.addWidget(self.combox_stop, 2, 1)
g.addWidget(self.chk_rts, 2, 3)
g.addWidget(self.chk_dtr, 2, 4)
g.addWidget(self.btn_open, 3, 3, 1, 2)
g.setColumnStretch(1, 1)
g.setColumnStretch(4, 1)
w.setLayout(g)
self.refresh_ports()
return w
def refresh_ports(self):
self.combox_port.clear()
for p in serial.tools.list_ports.comports():
name = p.device
desc = p.description if p.description else ''
self.combox_port.addItem(f'{name} {desc}' if desc else name)
def toggle_serial(self):
if self.ser and self.ser.is_open:
self.close_serial()
else:
self.open_serial()
def open_serial(self):
text = self.combox_port.currentText()
port = text.split(' ')[0] if text else ''
if not port:
QMessageBox.warning(self, '提示', '请选择串口')
return
try:
self.ser = serial.Serial(
port=port,
baudrate=int(self.combox_baud.currentText()),
bytesize=int(self.combox_data.currentText()),
parity=PARITIES[self.combox_parity.currentText()],
stopbits=STOPBITS[self.combox_stop.currentText()],
timeout=0.1,
)
try:
self.ser.rts = self.chk_rts.isChecked()
self.ser.dtr = self.chk_dtr.isChecked()
except Exception:
pass
self.btn_open.setText('关闭串口')
self.combox_port.setEnabled(False)
self.info(f'串口 {port} 已打开')
self.reader = threading.Thread(target=self.read_loop, daemon=True)
self.reader.start()
except Exception as e:
QMessageBox.warning(self, '错误', f'打开串口失败:\n{e}')
def close_serial(self):
try:
if self.ser:
self.ser.close()
except Exception:
pass
self.ser = None
self.btn_open.setText('打开串口')
self.combox_port.setEnabled(True)
self.info('串口已关闭')
def read_loop(self):
while self.ser and self.ser.is_open:
try:
n = self.ser.in_waiting
if n:
data = self.ser.read(n)
self.feed_receive(data, 'serial')
else:
time.sleep(0.01)
except Exception:
break
def send_reply(self, reply: bytes, sock=None, addr=None):
try:
if self.ser and self.ser.is_open:
self.ser.write(reply)
self.log_line('TX', reply, hex_mode=self.rb_reply_hex.isChecked())
except Exception:
pass
def actual_send(self, data: bytes) -> bool:
if not (self.ser and self.ser.is_open):
QMessageBox.warning(self, '提示', '请先打开串口')
return False
try:
self.ser.write(data)
return True
except Exception as e:
QMessageBox.warning(self, '错误', f'发送失败\n{e}')
return False
def collect_config(self):
cfg = super().collect_config()
cfg['rts'] = self.chk_rts.isChecked()
cfg['dtr'] = self.chk_dtr.isChecked()
return cfg
def apply_config(self, cfg):
msg = super().apply_config(cfg)
if isinstance(cfg, dict):
if 'rts' in cfg:
self.chk_rts.setChecked(bool(cfg['rts']))
if 'dtr' in cfg:
self.chk_dtr.setChecked(bool(cfg['dtr']))
return msg
class NetPanel(BasePanel):
"""TCP/UDP 通用面板,支持客户端与服务器模式"""
def __init__(self, kind: str):
self.kind = kind # 'tcp' / 'udp'
self.sock = None
self.clients = {}
self.lock = threading.Lock()
super().__init__('TCP' if kind == 'tcp' else 'UDP')
def build_top(self) -> QWidget:
w = QWidget()
g = QGridLayout(w)
self.combox_mode = QComboBox()
self.combox_mode.addItems(['客户端', '服务端'])
self.combox_mode.currentIndexChanged.connect(self.on_mode_changed)
self.edit_ip = QLineEdit('127.0.0.1')
self.spin_port = QSpinBox()
self.spin_port.setRange(1, 65535)
self.spin_port.setValue(8080)
self.btn_conn = QPushButton('连接')
self.btn_conn.clicked.connect(self.toggle_connect)
self.ip_label = QLabel('目标IP')
g.addWidget(QLabel('模式'), 0, 0)
g.addWidget(self.combox_mode, 0, 1)
g.addWidget(self.ip_label, 0, 2)
g.addWidget(self.edit_ip, 0, 3)
g.addWidget(QLabel('端口'), 0, 4)
g.addWidget(self.spin_port, 0, 5)
g.addWidget(self.btn_conn, 0, 6)
if self.kind == 'tcp':
self.client_bar = QWidget()
cb = QHBoxLayout(self.client_bar)
cb.setContentsMargins(0, 0, 0, 0)
self.combox_client = QComboBox()
self.btn_disconnect_all = QPushButton('断开所有')
self.btn_disconnect_all.clicked.connect(self.disconnect_all)
cb.addWidget(QLabel('客户端'))
cb.addWidget(self.combox_client, 1)
cb.addWidget(self.btn_disconnect_all)
g.addWidget(self.client_bar, 1, 0, 1, 7)
else:
self.client_bar = None
g.setColumnStretch(3, 1)
w.setLayout(g)
self.on_mode_changed(0)
return w
def on_mode_changed(self, _):
server = self.combox_mode.currentText() == '服务端'
if server:
self.edit_ip.setText(get_local_ip())
self.edit_ip.setEnabled(False)
self.ip_label.setText('本地IP')
if self.btn_conn.text() not in ('停止',):
pass
else:
self.edit_ip.setEnabled(True)
self.ip_label.setText('目标IP')
if self.kind == 'tcp' and self.client_bar is not None:
self.client_bar.setVisible(server)
def toggle_connect(self):
if self.combox_mode.currentText() == '客户端':
if self.sock:
self.close_client()
else:
self.open_client()
else:
if self.sock:
self.stop_server()
else:
self.start_server()
def open_client(self):
ip = self.edit_ip.text().strip()
port = self.spin_port.value()
if not ip:
QMessageBox.warning(self, '提示', '请输入目标IP')
return
try:
if self.kind == 'tcp':
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
sock.connect((ip, port))
sock.settimeout(0.5)
self.sock = sock
self.btn_conn.setText('断开')
self.info(f'已连接{ip}:{port}')
threading.Thread(target=self.tcp_read_loop,
args=(sock, ip, port), daemon=True).start()
else:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(0.5)
self.sock = sock
self.btn_conn.setText('关闭')
self.info(f'UDP 客户端就绪 -> {ip}:{port}')
except Exception as e:
QMessageBox.warning(self, '错误', f'连接失败:\n{e}')
def close_client(self):
try:
if self.sock:
self.sock.close()
except Exception:
pass
self.sock = None
self.btn_conn.setText('连接')
self.info('连接已关闭')
def start_server(self):
port = self.spin_port.value()
local = self.edit_ip.text().strip() or get_local_ip()
try:
if self.kind == 'tcp':
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(('0.0.0.0', port))
srv.listen(10)
srv.settimeout(0.5)
self.sock = srv
self.btn_conn.setText('停止')
self.info(f'TCP 服务器监听{local}:{port}')
threading.Thread(target=self.accept_loop, daemon=True).start()
else:
srv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
srv.bind(('0.0.0.0', port))
srv.settimeout(0.5)
self.sock = srv
self.btn_conn.setText('停止')
self.info(f'UDP 服务器监听{local}:{port}')
threading.Thread(target=self.udp_read_loop, daemon=True).start()
except Exception as e:
QMessageBox.warning(self, '错误', f'启动服务器失败\n{e}')
def stop_server(self):
try:
if self.sock:
self.sock.close()
except Exception:
pass
with self.lock:
for c in list(self.clients.values()):
try:
c.close()
except Exception:
pass
self.clients.clear()
self.sock = None
self.combox_client.clear()
self.btn_conn.setText('启动')
self.info('服务器已停止')
def accept_loop(self):
while self.sock:
try:
conn, addr = self.sock.accept()
except socket.timeout:
continue
except Exception:
break
conn.settimeout(0.5)
key = f'{addr[0]}:{addr[1]}'
with self.lock:
self.clients[key] = conn
self.run_ui(lambda k=key: self.combox_client.addItem(k))
self.info(f'客户端接入{key}')
threading.Thread(target=self.tcp_read_loop,
args=(conn, addr[0], addr[1]), daemon=True).start()
def tcp_read_loop(self, sock, ip, port):
while True:
try:
data = sock.recv(4096)
if not data:
break
self.feed_receive(data, f'{ip}:{port}', sock)
except socket.timeout:
continue
except Exception:
break
self.on_disconnected(sock, ip, port)
def udp_read_loop(self):
while self.sock:
try:
data, addr = self.sock.recvfrom(65536)
self.last_remote = addr
self.feed_receive(data, addr, self.sock, addr)
except socket.timeout:
continue
except Exception:
break
def send_reply(self, reply: bytes, sock=None, addr=None):
try:
if addr:
sock.sendto(reply, addr)
elif sock:
sock.sendall(reply)
else:
return
self.log_line('TX', reply, hex_mode=self.rb_reply_hex.isChecked())
except Exception:
pass
def on_disconnected(self, sock, ip, port):
key = f'{ip}:{port}'
with self.lock:
self.clients.pop(key, None)
try:
sock.close()
except Exception:
pass
self.run_ui(lambda k=key: self._ui_remove_client(k))
self.info(f'客户端{key} 断开')
def _ui_remove_client(self, key):
idx = self.combox_client.findText(key)
if idx >= 0:
self.combox_client.removeItem(idx)
def disconnect_all(self):
with self.lock:
for c in list(self.clients.values()):
try:
c.close()
except Exception:
pass
self.clients.clear()
self.combox_client.clear()
self.info('已断开所有客户端')
def actual_send(self, data: bytes) -> bool:
if self.kind == 'udp':
if not self.sock:
QMessageBox.warning(self, '提示', '请先连接或启动服务器')
return False
ip = self.edit_ip.text().strip() or '127.0.0.1'
try:
self.sock.sendto(data, (ip, self.spin_port.value()))
return True
except Exception as e:
QMessageBox.warning(self, '错误', f'发送失败\n{e}')
return False
# TCP
if self.combox_mode.currentText() == '客户端':
if not self.sock:
QMessageBox.warning(self, '提示', '请先连接服务器')
return False
try:
self.sock.sendall(data)
return True
except Exception as e:
QMessageBox.warning(self, '错误', f'发送失败\n{e}')
return False
# TCP 服务器
with self.lock:
targets = list(self.clients.values())
if not targets:
QMessageBox.warning(self, '提示', '没有已连接的客户端')
return False
ok = False
for c in targets:
try:
c.sendall(data)
ok = True
except Exception:
pass
return ok
class MainWindow(QMainWindow):
@staticmethod
def _icon_path() -> str:
if getattr(sys, 'frozen', False):
base = sys._MEIPASS
else:
base = os.path.dirname(os.path.abspath(__file__))
p = os.path.join(base, '网络调试.ico')
return p if os.path.exists(p) else ''
def __init__(self):
super().__init__()
self.setWindowTitle(APP_NAME)
self.resize(900, 680)
self.setMinimumSize(600, 460)
icon = self._icon_path()
if icon:
self.setWindowIcon(QIcon(icon))
tabs = QTabWidget()
self.serial_panel = SerialPanel()
self.tcp_panel = NetPanel('tcp')
self.udp_panel = NetPanel('udp')
tabs.addTab(self.serial_panel, '串口')
tabs.addTab(self.tcp_panel, 'TCP')
tabs.addTab(self.udp_panel, 'UDP')
self.setCentralWidget(tabs)
self.top_btn = QCheckBox('窗口置顶')
self.top_btn.setToolTip('选中后窗口保持在最前端')
self.top_btn.toggled.connect(self._toggle_topmost)
self.chk_dark = QCheckBox('深色模式')
self.chk_dark.setToolTip('切换亮色/暗色主题')
self.chk_dark.toggled.connect(self._toggle_dark)
self.chk_tray = QCheckBox('最小化到托盘')
self.chk_tray.setToolTip('最小化或关闭窗口时隐藏到系统托盘')
self.chk_tray.toggled.connect(self._on_tray_toggled)
tb = QToolBar('选项')
tb.setMovable(False)
tb.addWidget(self.top_btn)
tb.addWidget(self.chk_dark)
tb.addWidget(self.chk_tray)
tb.addSeparator()
self.combo_profile = NoWheelComboBox()
self.combo_profile.setMinimumWidth(150)
self.combo_profile.currentIndexChanged.connect(self._on_profile_selected)
tb.addWidget(QLabel(' 预设 '))
tb.addWidget(self.combo_profile)
btn_psave = QPushButton('保存预设')
btn_pdel = QPushButton('删除预设')
btn_psave.clicked.connect(self._save_profile)
btn_pdel.clicked.connect(self._del_profile)
tb.addWidget(btn_psave)
tb.addWidget(btn_pdel)
self.addToolBar(tb)
self._profiles = {}
self._tray_enabled = False
self._topmost_active = False
self._light_palette = QApplication.instance().palette()
self.tray_icon = None
self._autosave = QTimer(self)
self._autosave.setInterval(30000)
self._autosave.timeout.connect(self._save_app_config)
self._autosave.start()
self._build_tray()
self._load_profiles()
self._load_app_config()
def _toggle_topmost(self, on: bool):
self._topmost_active = on
try:
import ctypes
user32 = ctypes.windll.user32
user32.SetWindowPos.argtypes = [
ctypes.c_void_p, ctypes.c_void_p,
ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int,
ctypes.c_uint,
]
user32.SetWindowPos.restype = ctypes.c_int
HWND_TOPMOST = -1
HWND_NOTOPMOST = -2
SWP_NOMOVE = 0x0002
SWP_NOSIZE = 0x0001
SWP_NOACTIVATE = 0x0010
ok = user32.SetWindowPos(
ctypes.c_void_p(int(self.winId())),
ctypes.c_void_p(HWND_TOPMOST if on else HWND_NOTOPMOST),
0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE) != 0
if ok:
return
except Exception:
pass
self._fallback_topmost(on)
def _fallback_topmost(self, on: bool):
QTimer.singleShot(0, lambda: (self.setWindowFlag(Qt.WindowStaysOnTopHint, on),
self.show()))
def _toggle_dark(self, on: bool):
app = QApplication.instance()
if on:
p = QPalette()
p.setColor(QPalette.Window, QColor('#2b2b2b'))
p.setColor(QPalette.WindowText, QColor('#f0f0f0'))
p.setColor(QPalette.Base, QColor('#232323'))
p.setColor(QPalette.AlternateBase, QColor('#3c3f41'))
p.setColor(QPalette.Text, QColor('#f0f0f0'))
p.setColor(QPalette.Button, QColor('#3c3f41'))
p.setColor(QPalette.ButtonText, QColor('#f0f0f0'))
p.setColor(QPalette.BrightText, QColor('#ff5555'))
p.setColor(QPalette.Highlight, QColor('#2a82da'))
p.setColor(QPalette.HighlightedText, QColor('#ffffff'))
p.setColor(QPalette.Link, QColor('#2a82da'))
p.setColor(QPalette.ToolTipBase, QColor('#f0f0f0'))
p.setColor(QPalette.ToolTipText, QColor('#2b2b2b'))
p.setColor(QPalette.Disabled, QPalette.Text, QColor('#787878'))
p.setColor(QPalette.Disabled, QPalette.ButtonText, QColor('#787878'))
p.setColor(QPalette.Disabled, QPalette.Base, QColor('#2b2b2b'))
app.setStyle('Fusion')
app.setPalette(p)
else:
app.setStyle('Fusion')
app.setPalette(self._light_palette)
def _build_tray(self):
icon = QIcon(self._icon_path())
if icon.isNull():
self.tray_icon = None
return
self.tray_icon = QSystemTrayIcon(icon, self)
menu = QMenu(self)
act_show = menu.addAction('显示主界面')
act_show.triggered.connect(self._show_from_tray)
menu.addSeparator()
act_quit = menu.addAction('退出')
act_quit.triggered.connect(self._quit_app)
self.tray_icon.setContextMenu(menu)
self.tray_icon.activated.connect(self._on_tray_activated)
def _on_tray_toggled(self, checked: bool):
self._tray_enabled = checked
if self.tray_icon:
if checked:
self.tray_icon.show()
else:
self.tray_icon.hide()
def _on_tray_activated(self, reason):
if reason == QSystemTrayIcon.Trigger:
self._show_from_tray()
def _show_from_tray(self):
self.showNormal()
self.show()
self.raise_()
self.activateWindow()
def _quit_app(self):
self._tray_enabled = False
if self.tray_icon:
self.tray_icon.hide()
self.close()
def changeEvent(self, event):
if event.type() == QEvent.WindowStateChange and self.isMinimized() \
and self._tray_enabled and self.tray_icon and self.tray_icon.isVisible():
QTimer.singleShot(0, self.hide)
super().changeEvent(event)
def info(self, text: str):
self.statusBar().showMessage(text, 3000)
def _collect_all_config(self):
return {
'topmost': self.top_btn.isChecked(),
'dark': self.chk_dark.isChecked(),
'tray': self.chk_tray.isChecked(),
'tabs': {
'serial': self.serial_panel.collect_config(),
'tcp': self.tcp_panel.collect_config(),
'udp': self.udp_panel.collect_config(),
},
}
def _apply_panels(self, data):
tabs_cfg = data.get('tabs') if isinstance(data, dict) else None
if not isinstance(tabs_cfg, dict):
return
for panel, key in ((self.serial_panel, 'serial'),
(self.tcp_panel, 'tcp'),
(self.udp_panel, 'udp')):
cfg = tabs_cfg.get(key)
if isinstance(cfg, dict):
panel.apply_config(cfg)
def _save_app_config(self):
try:
with open(app_config_path(), 'w', encoding='utf-8') as f:
json.dump(self._collect_all_config(), f, ensure_ascii=False, indent=2)
except Exception:
pass
def _load_app_config(self):
try:
with open(app_config_path(), 'r', encoding='utf-8') as f:
data = json.load(f)
except Exception:
return
if not isinstance(data, dict):
return
if data.get('topmost'):
self.top_btn.setChecked(True)
if data.get('dark'):
self.chk_dark.setChecked(True)
if data.get('tray'):
self.chk_tray.setChecked(True)
self._apply_panels(data)
def _profile_path(self):
return os.path.splitext(app_config_path())[0] + '_profiles.json'
def _load_profiles(self):
try:
with open(self._profile_path(), 'r', encoding='utf-8') as f:
data = json.load(f)
self._profiles = data if isinstance(data, dict) else {}
except Exception:
self._profiles = {}
self._reload_profiles_combo()
def _write_profiles(self):
try:
with open(self._profile_path(), 'w', encoding='utf-8') as f:
json.dump(self._profiles, f, ensure_ascii=False, indent=2)
except OSError:
pass
def _reload_profiles_combo(self, select=None):
self.combo_profile.blockSignals(True)
self.combo_profile.clear()
self.combo_profile.addItem('← 选择预设 →')
for name in self._profiles:
self.combo_profile.addItem(name)
select = self.combo_profile.currentText() if select is None else select
idx = self.combo_profile.findText(select)
if idx >= 0 and idx != 0:
self.combo_profile.setCurrentIndex(idx)
self.combo_profile.blockSignals(False)
def _on_profile_selected(self, index):
if index <= 0:
return
name = self.combo_profile.currentText()
cfg = self._profiles.get(name)
if not isinstance(cfg, dict):
return
if cfg.get('topmost'):
self.top_btn.setChecked(True)
else:
self.top_btn.setChecked(False)
if cfg.get('dark'):
self.chk_dark.setChecked(True)
else:
self.chk_dark.setChecked(False)
if cfg.get('tray'):
self.chk_tray.setChecked(True)
else:
self.chk_tray.setChecked(False)
self._apply_panels(cfg)
self._save_app_config()
self.info('预设已加载: ' + name)
def _save_profile(self):
text = self.combo_profile.currentText()
if text == '← 选择预设 →':
text = ''
name, ok = QInputDialog.getText(self, '保存预设', '预设名称:', QLineEdit.Normal, text)
if not ok or not name.strip():
return
self._profiles[name.strip()] = self._collect_all_config()
self._write_profiles()
self._reload_profiles_combo(name.strip())
self.info('预设已保存: ' + name.strip())
def _del_profile(self):
name = self.combo_profile.currentText()
if name == '← 选择预设 →' or name not in self._profiles:
return
if QMessageBox.question(self, '删除预设', f'确定删除预设「{name}」吗?') \
!= QMessageBox.Yes:
return
del self._profiles[name]
self._write_profiles()
self._reload_profiles_combo()
self.info('预设已删除: ' + name)
def closeEvent(self, event):
self._save_app_config()
if self._tray_enabled and self.tray_icon and self.tray_icon.isVisible():
event.ignore()
self.hide()
self.tray_icon.showMessage(APP_NAME, '程序已最小化到托盘',
QSystemTrayIcon.Information, 2000)
return
if self.tray_icon:
self.tray_icon.hide()
super().closeEvent(event)
if __name__ == '__main__':
QApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)
app = QApplication(sys.argv)
app.setApplicationName(APP_NAME)
app.setFont(QFont('Microsoft YaHei', 9))
win = MainWindow()
win.show()
sys.exit(app.exec())