@@ -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 ) )