2 Commits

3 changed files with 681 additions and 41 deletions
+5 -1
View File
@@ -15,4 +15,8 @@ decompiled_network_debugger.py
network_debugger_repaired.py network_debugger_repaired.py
clean_consts.txt clean_consts.txt
mod_doc.txt mod_doc.txt
docs.json docs.json
# 运行时生成的应用配置
app_config.json
*_profiles.json
+50
View File
@@ -0,0 +1,50 @@
# 修改事项
以下为相对于上一提交(b43249f)的改动记录。
## 1. 收发统计栏
- 底部新增统计标签,实时显示 **TX/RX 的字节数与帧数**
## 2. 接收区右键菜单
- 新增 `RecvEdit.contextMenuEvent`:复制、全选、**另存为**、清空。
- "另存为"将接收区全部内容写入文本文件。
## 3. 发送区 Ctrl+滚轮调字号
- `SendEdit` 支持 Ctrl+滚轮调整字号(8~40pt),字号随配置持久化。
## 4. 关键字过滤
- 接收设置组新增"过滤"勾选 + 关键字输入框:启用后只显示包含关键字的行。
- 过滤状态与关键字随配置保存/恢复。
## 5. 发送历史持久化
- 发送历史(最近 200 条)随 `app_config.json` 保存,重启后可用 ↑↓ 键继续浏览。
## 6. 多套配置预设(Profile
- 顶栏"预设"下拉 + "保存预设/删除预设"按钮,可一键切换整套配置。
- 预设保存于 `network_debugger_profiles.json`
## 7. 深色主题切换
- 顶栏"深色模式"勾选框,基于 Fusion 风格切换亮/暗色板,状态持久化。
## 8. DPI 适配
- 启动时设置高分屏缩放舍入策略 `PassThrough`,避免高分屏模糊。
## 9. 串口 RTS/DTR 控制 + 发送文件
- 串口面板新增 RTS/DTR 勾选,打开串口时生效,随配置保存。
- 新增"发送文件"按钮:读取文件按 4KB 分块发送,避免阻塞界面。
## 10. 接收区性能优化
- 接收区改为**批量插入 + 定时(120ms)刷新**,高频率收发不再逐行刷新导致卡顿。
## 11. 窗口置顶修复:弹窗继承置顶
- 新增 `inherit_topmost()`:主窗口置顶时,批量发送/应答规则/自动应答弹窗自动带上置顶标志,避免被主窗口遮住。
- 修复 `BatchSendDialog` 未传 `parent` 导致无法继承置顶的问题。
## 12. 最小化到托盘
- 顶栏"最小化到托盘"勾选:勾选后显示托盘图标,**最小化或关闭**均隐藏到托盘,托盘菜单可"显示主界面/退出"。
- 修复托盘图标未调用 `show()` 导致功能无效的问题。
## 13. 配置保存修复与自动保存
- 修复残留隐藏 `app_config.json` 被锁定导致保存 `PermissionError`(静默失败)的问题。
- 新增每 30 秒自动保存兜底,异常退出也不易丢配置。
- 删除单实例锁,**默认支持多开**;`app_config.json` 加入 `.gitignore`
+626 -40
View File
@@ -18,14 +18,15 @@ from datetime import datetime
import serial import serial
import serial.tools.list_ports import serial.tools.list_ports
from PySide6.QtCore import Qt, QTimer, Signal, QSize, QRect, QPoint from PySide6.QtCore import Qt, QTimer, Signal, QSize, QRect, QPoint, QEvent
from PySide6.QtGui import QFont, QColor, QTextCharFormat, QTextCursor, QIcon from PySide6.QtGui import QFont, QColor, QTextCharFormat, QTextCursor, QIcon, QPalette
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout, QHBoxLayout, QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout, QHBoxLayout,
QGridLayout, QLabel, QComboBox, QLineEdit, QPushButton, QDialog, QGridLayout, QLabel, QComboBox, QLineEdit, QPushButton, QDialog,
QPlainTextEdit, QCheckBox, QSpinBox, QSplitter, QMessageBox, QPlainTextEdit, QCheckBox, QSpinBox, QSplitter, QMessageBox,
QTableWidget, QTableWidgetItem, QListWidget, QListWidgetItem, QTableWidget, QTableWidgetItem, QListWidget, QListWidgetItem,
QGroupBox, QRadioButton, QHeaderView, QFileDialog, QWidgetItem, QLayout, QGroupBox, QRadioButton, QHeaderView, QFileDialog, QWidgetItem, QLayout,
QToolBar, QMenu, QInputDialog, QSystemTrayIcon,
) )
STAMP_OPTS = ['', 'ASCII(YYYY-MM-DD HH:MM:SS)', 'ASCII(HH:MM:SS)', STAMP_OPTS = ['', 'ASCII(YYYY-MM-DD HH:MM:SS)', 'ASCII(HH:MM:SS)',
@@ -40,6 +41,16 @@ WIDTH_OPTS = ['1', '2', '4']
ENDIAN_OPTS = ['小端', '大端'] 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): class FlowLayout(QLayout):
"""可自动换行的流式布局:窗口过窄时控件自动换到下一行,不会显示不全""" """可自动换行的流式布局:窗口过窄时控件自动换到下一行,不会显示不全"""
@@ -116,11 +127,30 @@ class FlowLayout(QLayout):
class SendEdit(QPlainTextEdit): class SendEdit(QPlainTextEdit):
"""回车即发送的发送框,支持↑↓键浏览发送历史""" """回车即发送的发送框,支持↑↓键浏览发送历史Ctrl+滚轮调整字号"""
enterPressed = Signal() enterPressed = Signal()
histNav = Signal(int) 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): def keyPressEvent(self, e):
if e.key() in (Qt.Key_Return, Qt.Key_Enter) and not (e.modifiers() & Qt.ShiftModifier): if e.key() in (Qt.Key_Return, Qt.Key_Enter) and not (e.modifiers() & Qt.ShiftModifier):
self.enterPressed.emit() self.enterPressed.emit()
@@ -132,6 +162,49 @@ class SendEdit(QPlainTextEdit):
super().keyPressEvent(e) 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): class NoWheelSpinBox(QSpinBox):
def wheelEvent(self, event): def wheelEvent(self, event):
event.ignore() event.ignore()
@@ -191,6 +264,17 @@ def match_rule(hex_text: str, data: bytes) -> bool:
return find_match(hex_text, data) != -1 return find_match(hex_text, data) != -1
APP_NAME = "网络调试助手" 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'] BAUDS = ['1200', '2400', '4800', '9600', '19200', '38400', '57600', '115200']
PARITIES = {'无校验': serial.PARITY_NONE, '奇校验': serial.PARITY_ODD, PARITIES = {'无校验': serial.PARITY_NONE, '奇校验': serial.PARITY_ODD,
'偶校验': serial.PARITY_EVEN} '偶校验': serial.PARITY_EVEN}
@@ -477,6 +561,7 @@ def write_field(ftype, widget, field):
class RuleEditDialog(QDialog): class RuleEditDialog(QDialog):
def __init__(self, parent=None, rule=None): def __init__(self, parent=None, rule=None):
super().__init__(parent) super().__init__(parent)
inherit_topmost(self)
self.setWindowTitle('应答规则') self.setWindowTitle('应答规则')
self.setMinimumWidth(800) self.setMinimumWidth(800)
v = QVBoxLayout(self) v = QVBoxLayout(self)
@@ -631,6 +716,7 @@ class RuleEditDialog(QDialog):
class AutoReplyManagerDialog(QDialog): class AutoReplyManagerDialog(QDialog):
def __init__(self, rules, parent=None): def __init__(self, rules, parent=None):
super().__init__(parent) super().__init__(parent)
inherit_topmost(self)
self.setWindowTitle('自动应答规则') self.setWindowTitle('自动应答规则')
self.setMinimumSize(520, 380) self.setMinimumSize(520, 380)
self.rules = [dict(r) for r in rules] self.rules = [dict(r) for r in rules]
@@ -742,6 +828,7 @@ class BatchSendDialog(QDialog):
def __init__(self, panel, parent=None): def __init__(self, panel, parent=None):
super().__init__(parent) super().__init__(parent)
inherit_topmost(self)
self.panel = panel self.panel = panel
self.setWindowTitle('批量发送') self.setWindowTitle('批量发送')
self.setMinimumSize(560, 360) self.setMinimumSize(560, 360)
@@ -961,6 +1048,16 @@ class BasePanel(QWidget):
self._log_hour = None self._log_hour = None
self.log_rotate_timer = QTimer(self) self.log_rotate_timer = QTimer(self)
self.log_rotate_timer.timeout.connect(self._rotate_log) 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.root = QVBoxLayout(self)
@@ -975,10 +1072,12 @@ class BasePanel(QWidget):
rv = QVBoxLayout(recv_widget) rv = QVBoxLayout(recv_widget)
rv.setContentsMargins(0, 0, 0, 0) rv.setContentsMargins(0, 0, 0, 0)
rv.addWidget(QLabel('接收区')) rv.addWidget(QLabel('接收区'))
self.recv = QPlainTextEdit() self.recv = RecvEdit()
self.recv.setReadOnly(True) self.recv.setReadOnly(True)
self.recv.setMaximumBlockCount(2000) self.recv.setMaximumBlockCount(2000)
self.recv.setStyleSheet('QPlainTextEdit{font-family:Consolas;font-size:11pt}') 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) rv.addWidget(self.recv, 1)
splitter.addWidget(recv_widget) splitter.addWidget(recv_widget)
@@ -988,7 +1087,7 @@ class BasePanel(QWidget):
sv.addWidget(QLabel('发送区')) sv.addWidget(QLabel('发送区'))
self.send = SendEdit() self.send = SendEdit()
self.send.setMaximumBlockCount(2000) self.send.setMaximumBlockCount(2000)
self.send.setStyleSheet('QPlainTextEdit{font-family:Consolas;font-size:11pt}') self.send.setFont(QFont('Consolas', 11))
self.send.enterPressed.connect(self.on_enter_pressed) self.send.enterPressed.connect(self.on_enter_pressed)
self.send.histNav.connect(self.history_nav) self.send.histNav.connect(self.history_nav)
sv.addWidget(self.send) sv.addWidget(self.send)
@@ -1027,6 +1126,15 @@ class BasePanel(QWidget):
self.chk_info = QCheckBox('INFO') self.chk_info = QCheckBox('INFO')
self.chk_info.setChecked(True) self.chk_info.setChecked(True)
hr.addWidget(self.chk_info) 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 = QCheckBox('保存日志')
self.chk_save_log.toggled.connect(self.toggle_save_log) self.chk_save_log.toggled.connect(self.toggle_save_log)
hr.addWidget(self.chk_save_log) hr.addWidget(self.chk_save_log)
@@ -1092,19 +1200,24 @@ class BasePanel(QWidget):
self.btn_import.clicked.connect(self.import_config) self.btn_import.clicked.connect(self.import_config)
self.btn_send = QPushButton('发送') self.btn_send = QPushButton('发送')
self.btn_batch = QPushButton('批量发送') self.btn_batch = QPushButton('批量发送')
self.btn_send_file = QPushButton('发送文件')
self.btn_clear_recv = QPushButton('清空接收') self.btn_clear_recv = QPushButton('清空接收')
self.btn_clear_send = QPushButton('清空发送') self.btn_clear_send = QPushButton('清空发送')
self.btn_send.clicked.connect(self.doSend) self.btn_send.clicked.connect(self.doSend)
self.btn_batch.clicked.connect(self.open_batch) 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_recv.clicked.connect(self.recv.clear)
self.btn_clear_send.clicked.connect(self.send.clear) self.btn_clear_send.clicked.connect(self.send.clear)
btn_row.addWidget(self.btn_export) btn_row.addWidget(self.btn_export)
btn_row.addWidget(self.btn_import) btn_row.addWidget(self.btn_import)
btn_row.addWidget(self.btn_send) btn_row.addWidget(self.btn_send)
btn_row.addWidget(self.btn_batch) 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_recv)
btn_row.addWidget(self.btn_clear_send) btn_row.addWidget(self.btn_clear_send)
btn_row.addStretch() 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.root.addLayout(btn_row)
self.setWindowTitle(title) self.setWindowTitle(title)
@@ -1117,7 +1230,7 @@ class BasePanel(QWidget):
self.doSend() self.doSend()
def open_batch(self): def open_batch(self):
dlg = BatchSendDialog(self) dlg = BatchSendDialog(self, parent=self)
if self.batch_rows: if self.batch_rows:
dlg.import_config(self.batch_rows) dlg.import_config(self.batch_rows)
dlg.exec() dlg.exec()
@@ -1146,22 +1259,67 @@ class BasePanel(QWidget):
payload = bytes_to_hex(data) payload = bytes_to_hex(data)
else: else:
payload = data.decode('utf-8', 'replace') 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 '' text = f'{ts()}' if self.chk_ts.isChecked() else ''
color = 'blue' if direction == 'TX' else 'green' color = 'blue' if direction == 'TX' else 'green'
self._append_line(f'[{text}] {direction}: {payload}' if text else f'{direction}: {payload}', color) self._append_line(f'[{text}] {direction}: {payload}' if text else f'{direction}: {payload}', color)
def _append_colored(self, text: str, color: str): 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 = self.recv.textCursor()
cur.movePosition(QTextCursor.End) cur.movePosition(QTextCursor.End)
if color: fmt_cache = {}
fmt = QTextCharFormat() for text, color in self._append_buffer:
fmt.setForeground(QColor(color)) if color not in fmt_cache:
cur.setCharFormat(fmt) f = QTextCharFormat()
cur.insertText(text + '\n') 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) self.recv.setTextCursor(cur)
bar = self.recv.verticalScrollBar() bar = self.recv.verticalScrollBar()
bar.setValue(bar.maximum()) 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): def _append_line(self, text: str, color: str = None):
self._append_colored(text, color) self._append_colored(text, color)
if self._log_fh or self._log_dir: if self._log_fh or self._log_dir:
@@ -1221,6 +1379,36 @@ class BasePanel(QWidget):
def actual_send(self, data: bytes) -> bool: def actual_send(self, data: bytes) -> bool:
raise NotImplementedError 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): def send_reply(self, reply: bytes, sock=None, addr=None):
raise NotImplementedError raise NotImplementedError
@@ -1341,9 +1529,114 @@ class BasePanel(QWidget):
if dlg.exec() == QDialog.Accepted: if dlg.exec() == QDialog.Accepted:
self.reply_rules = dlg.result() 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): def export_config(self):
data = {'reply_rules': copy.deepcopy(self.reply_rules), data = self.collect_config()
'batch_rows': copy.deepcopy(self.batch_rows)}
default = f'config_{datetime.now():%Y%m%d_%H%M%S}.json' default = f'config_{datetime.now():%Y%m%d_%H%M%S}.json'
path, _ = QFileDialog.getSaveFileName(self, '导出配置', default, path, _ = QFileDialog.getSaveFileName(self, '导出配置', default,
'JSON 文件 (*.json);;所有文件 (*)') 'JSON 文件 (*.json);;所有文件 (*)')
@@ -1370,22 +1663,7 @@ class BasePanel(QWidget):
if not isinstance(data, dict): if not isinstance(data, dict):
QMessageBox.warning(self, '导入失败', '配置文件格式无效') QMessageBox.warning(self, '导入失败', '配置文件格式无效')
return return
msg = [] msg = self.apply_config(data)
rr = data.get('reply_rules')
if isinstance(rr, list) and rr 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 = data.get('batch_rows')
if isinstance(br, list):
if not all(isinstance(x, dict) for x in br):
QMessageBox.warning(self, '导入失败', '批量发送配置格式无效')
return
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)}')
if not msg: if not msg:
QMessageBox.warning(self, '导入失败', '配置文件中没有可导入的数据') QMessageBox.warning(self, '导入失败', '配置文件中没有可导入的数据')
return return
@@ -1490,6 +1768,10 @@ class SerialPanel(BasePanel):
self.btn_refresh.clicked.connect(self.refresh_ports) self.btn_refresh.clicked.connect(self.refresh_ports)
self.btn_open = QPushButton('打开串口') self.btn_open = QPushButton('打开串口')
self.btn_open.clicked.connect(self.toggle_serial) 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(QLabel('串口'), 0, 0)
g.addWidget(self.combox_port, 0, 1) g.addWidget(self.combox_port, 0, 1)
@@ -1502,7 +1784,9 @@ class SerialPanel(BasePanel):
g.addWidget(self.combox_parity, 1, 4) g.addWidget(self.combox_parity, 1, 4)
g.addWidget(QLabel('停止位'), 2, 0) g.addWidget(QLabel('停止位'), 2, 0)
g.addWidget(self.combox_stop, 2, 1) g.addWidget(self.combox_stop, 2, 1)
g.addWidget(self.btn_open, 2, 3, 1, 2) 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(1, 1)
g.setColumnStretch(4, 1) g.setColumnStretch(4, 1)
w.setLayout(g) w.setLayout(g)
@@ -1537,6 +1821,11 @@ class SerialPanel(BasePanel):
stopbits=STOPBITS[self.combox_stop.currentText()], stopbits=STOPBITS[self.combox_stop.currentText()],
timeout=0.1, 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.btn_open.setText('关闭串口')
self.combox_port.setEnabled(False) self.combox_port.setEnabled(False)
self.info(f'串口 {port} 已打开') self.info(f'串口 {port} 已打开')
@@ -1587,6 +1876,21 @@ class SerialPanel(BasePanel):
QMessageBox.warning(self, '错误', f'发送失败\n{e}') QMessageBox.warning(self, '错误', f'发送失败\n{e}')
return False 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): class NetPanel(BasePanel):
"""TCP/UDP 通用面板,支持客户端与服务器模式""" """TCP/UDP 通用面板,支持客户端与服务器模式"""
@@ -1847,12 +2151,8 @@ class NetPanel(BasePanel):
QMessageBox.warning(self, '错误', f'发送失败\n{e}') QMessageBox.warning(self, '错误', f'发送失败\n{e}')
return False return False
# TCP 服务器 # TCP 服务器
sel = self.combox_client.currentText()
with self.lock: with self.lock:
if sel and sel in self.clients: targets = list(self.clients.values())
targets = [self.clients[sel]]
else:
targets = list(self.clients.values())
if not targets: if not targets:
QMessageBox.warning(self, '提示', '没有已连接的客户端') QMessageBox.warning(self, '提示', '没有已连接的客户端')
return False return False
@@ -1885,13 +2185,299 @@ class MainWindow(QMainWindow):
if icon: if icon:
self.setWindowIcon(QIcon(icon)) self.setWindowIcon(QIcon(icon))
tabs = QTabWidget() tabs = QTabWidget()
tabs.addTab(SerialPanel(), '串口') self.serial_panel = SerialPanel()
tabs.addTab(NetPanel('tcp'), 'TCP') self.tcp_panel = NetPanel('tcp')
tabs.addTab(NetPanel('udp'), 'UDP') 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.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__': if __name__ == '__main__':
QApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setApplicationName(APP_NAME) app.setApplicationName(APP_NAME)
app.setFont(QFont('Microsoft YaHei', 9)) app.setFont(QFont('Microsoft YaHei', 9))