一、小程序功能设计
1、手柄控制信号产生
(1)功能背景和定义:
①在嵌入式软件开发相关的岗位的工作中,经常有需要接收外部控制信号的外设,控制链路类似于使用手柄操作遥控车,但有时候往往没有“手柄”资源(这个“手柄”并不是指狭义的手柄,而是指能够产生遥控控制信号的设备),那么调试的难度将会大大增加。
②为了方便调试,可以使用上位机程序产生模拟的手柄控制信号,以此将调试进行下去。
③上位机提供指令导入和实时控制两种方式:
[1]指令导入:用户可以选择带有时间列和控制指令列的CSV文件,将其导入小程序,在收发控制台中打开通信接口,并且勾选“开始回放”后,小程序会使用CSV文件中的控制指令作为轴的目标位置,按照用户配置的周期改变轴的目标值,并完成组帧和下发。
[2]实时控制:用户可以直接配置各轴的参数,并通过长按按键改变轴的目标值。
(2)手柄全局配置:
①手柄模拟器最大支持8轴同时配置,写入轴数后点击“更新轴布局”,即可更新轴数。

②手柄模拟器支持输出绝对位置信号和相对位移信号(单次位移量),如果选择相对位移,那么相对位移量的计算周期取决于收发控制台中配置的报文发送周期。

(3)指令导入模式:
①点击“导入CSV文件”,即可选择包含时间列和控制指令列的CSV文件,将其导入小程序。

②导入CSV文件后,需要配置时间列以及各轴对应的控制信号列,如果控制信号列为空,说明该轴不回放数据。需要注意的是,时间列的最大值如果小于100,则单位视为秒,否则视为毫秒,该设计主要考虑不同时间单位的CSV数据,用户可进行二次开发,手动配置时间列单位。

③在收发控制台中打开通信接口,然后勾选“开始回放”,小程序会使用CSV文件中的控制指令作为轴的目标位置,按照用户配置的发送周期改变轴的目标值(发送周期在收发控制台配置),并完成组帧和下发(组帧和下发由收发控制台以及私有协议模块完成,手柄控制台仅负责生成控制信号)。

(4)模拟手柄控制:
①鼠标按“▶”,可控制对应轴的目标位置往正极限位置移动,支持长按持续移动。

②鼠标按“◀”,可控制对应轴的目标位置往负极限位置移动,支持长按持续移动。

③可根据项目的实际情况定义模拟手柄各轴的单步灵敏度、噪声强度(高斯噪声幅值,高斯噪声用于模拟手柄信号的毛刺)以及上下边界值。

2、手柄信号封装成数据帧
(1)功能背景和定义:
①在实际开发中,手柄控制信号的传递往往被私有协议封装为数据帧,所以,只有单独的手柄控制信号产生,是远远不够的(手柄控制选项卡负责生成手柄控制信号,不负责组帧和发送)。
②为了支持各种各样的私有协议,小程序同时提供协议字段编辑功能,不过考虑到协议编辑不是本小程序的主要功能,小程序的协议编辑功能不会过于强大,仅支持固定常量段、手柄信号段和CRC校验段三种字段配置。
(2)功能定义:
①协议模板配置:
[1]点击“新建配置”,即可新建一个协议模板。

[2]在配置协议字段后,点击“保存为配置”,即可将所配置的协议模板保存,后续在打开小程序时,在“选择配置”的下拉框里能直接选择该模板。

[3]在“选择配置”的下拉框里选择需要导入的模板,然后点击“导入配置”,即可将协议模板导入工作区。

[4]在“选择配置”的下拉框里选择需要删除的模板,然后点击“删除配置”,即可将协议模板删除。

②大小端配置:
在“字节序选择”的下拉框里可以选择组包使用大端还是小端,该配置项只影响控制轴目标值字段,具体见协议字段配置功能介绍

③协议字段配置:
[1]字段大类选择“const”(常量字段),需要配置字段名称、字段长度以及常量字段的具体内容,然后点击“添加此字段”,即可配置一个常量字段(常量字段不受大小端影响,配置的十六进制值即原始数据流)。

[2]字段大类选择“axis”(控制轴目标值字段),需要配置字段名称、字段长度、绑定的目标轴轴号以及编码格式,然后点击“添加此字段”,即可配置一个控制轴目标值字段。
#1 控制轴目标值字段需要配置编码格式,并且匹配对应的字段长度,可选的格式有float32、int16、int32、Q15、Q16、Q30、Q31以及自定义转换。
#2 如果选择自定义转换,用户需要自行定义转换公式,原始值是float32类型,变量名为value,如果选择int类型,则小程序会根据用户设定的上下限边界值,将轴目标值线性映射在int类型的取值范围上。

[3]字段大类选择“crc”(CRC校验字段),需要配置字段名称、字段长度、CRC参数模型、校验范围以及输出字节序,然后点击“添加此字段”,即可配置一个CRC字段(需要注意的是,字段长度要与CRC参数模型匹配,参数模型支持自定义,校验范围禁止与CRC字段本身重叠)。

[4]选中列表中的字段条目,点击“删除选中”即可将其移除,如果直接点击“清空全部协议”,将清空整个协议模板的配置。

3、数据帧收发控制
(1)功能背景和定义:
①以上两个设计实现了手柄控制信号生成和数据帧组包的功能,但如果只能生成十六进制的数据流,而报文发送需要借助其它上位机进行的话,还是无法实现模拟手柄控制的功能。
②为了解决以上问题,小程序同样需要配置报文发送功能,数据帧组包后,小程序内部能够直接调用发送接口,将数据包发送出去,无需用户手动复制,实现高频控制信号的自动发送。
③小程序计划开发串口(TTL/RS232)、CAN和USB(区分CDC和HID)三个比较常用的通信接口(考虑到RS485通常和Modbus配合使用,Modbus有专门的上位机,所以暂不开发RS485通信接口),在实际开发中如果有其它通信接口需求,可在该小程序的基础上继续增加功能。
(2)功能定义:
①通信通道选择与配置:
[1]串口(TTL/RS232):

[2]USB-CDC:

[3]CAN总线:

[4]USB-HID:

②报文收发控制:
[1]打开通信接口后,如果点击“单次发送当前协议帧”,在绝对位置模式下,小程序会根据当前轴目标和协议模板组数据帧,然后发送;在相对位置模式下,小程序会根据缓冲中累计的轴目标位移量和协议模板组数据帧,然后发送,同时重置缓冲的累计值。

[2]打开通信接口后,如果勾选“周期发送”,那么在绝对位置模式下,小程序会按照用户配置的周期,根据当前轴目标和协议模板组数据帧,然后周期性地发送;在相对位置模式下,小程序会按照用户配置的周期,根据缓冲中累计的轴目标位移量和协议模板组数据帧,然后周期性地发送,同时重置缓冲中的累计值。

③日志打印:
[1]为了便于调试,可以选择在收发日志窗口打印收发的数据及通信接口的状态,但需要注意的是,在总线数据量庞大的情况下,必须取消勾选“打印日志”,防止小程序卡崩。
[2]点击“清除全部日志”,即可清空日志窗口的数据。

二、小程序开发
1、开发环境
(1)需要确认安装的Python是否自带tkinter库(图形依赖),有则无需单独安装。
(2)安装pyinstaller库,该库主要用于将.py打包成可执行文件.exe,使小程序运行能够脱离Python环境。
(3)安装serial库、can库、hid库,这几个库用于支持通信接口开发。
(4)安装numpy库,该库用于支持复杂的科学计算。
2、程序源码
(1)配置文件.json示例:
①协议模板配置文件protocol_configs.json示例:
{
"configs": [
{
"name": "测试模板",
"endian": "little",
"fields": [
{
"name": "帧头",
"class": "const",
"byte_len": 2,
"const_hex": "0A A0",
"desc": "常量:0A A0"
},
{
"name": "1号轴目标值",
"class": "axis",
"byte_len": 4,
"bind_ax": 1,
"encode": "custom(自定义表达式)",
"expr_type": "int",
"expr": "int(value*32768)",
"desc": "轴1 custom(自定义表达式) [expr: int(value*32768) | int]"
},
{
"name": "校验字段",
"class": "crc",
"byte_len": 2,
"std_mod": "crc16-modbus",
"poly": "0x8005",
"init": "0xFFFF",
"xor_out": "0x0",
"ref_in": true,
"ref_out": true,
"c_start": 0,
"c_len": 6,
"crc_out_endian": "little",
"desc": "crc16-modbus 范围0~5,输出字节序:little"
}
]
}
],
"last_used": "测试模板"
}
②通信参数及手柄控制参数持久化文件control_comm_config.json示例:
{
"comm_type": "TTL_RS232",
"log_enabled": true,
"joystick": {
"axis_count": 2,
"mode": "abs",
"sensitivity": 1.0,
"noise_amp": 0.2,
"abs_min": -100.0,
"abs_max": 100.0,
"per_axis": [
{
"sensitivity": 0.1,
"noise_amp": 0.2,
"abs_min": -100.0,
"abs_max": 100.0
},
{
"sensitivity": 1.0,
"noise_amp": 0.2,
"abs_min": -100.0,
"abs_max": 100.0
}
]
},
"serial": {
"port": "COM12",
"baud": "9600",
"data": "8",
"stop": "1",
"parity": "N无"
},
"can": {
"mode": "CAN2.0",
"dlc": "8",
"interface": "pcan",
"chan": "0",
"txid": "7FF",
"arb_baud": "500000",
"fd_baud": "2000000",
"ext": false
},
"usb": {
"vid": "1234",
"pid": "5678",
"report_len": "64"
}
}
(2)程序源码文件control_system.py:
import tkinter as tk
from tkinter import ttk, messagebox, scrolledtext, filedialog
import numpy as np
import serial
import serial.tools.list_ports
import struct
import threading
import json
import os
import time
import ast
import math
import queue
import csv
try:
import can
CAN_AVAIL = True
except ImportError:
can = None
CAN_AVAIL = False
try:
import hid
HID_AVAIL = True
except ImportError:
hid = None
HID_AVAIL = False
# 协议配置模板文件
CONFIG_FILE = "protocol_configs.json"
# 通信参数持久化文件
COMM_CFG_FILE = "control_comm_config.json"
# 手柄长按按钮的持续移动刷新周期(ms)
HOLD_TICK_MS = 15
# CAN波特率与适配器接口
CAN_ARB_BAUD = [125000, 250000, 500000, 800000, 1000000]
CAN_FD_BAUD = [500000, 1000000, 2000000, 4000000, 8000000]
CAN_INTERFACES = ["pcan", "canalystii", "slcan", "usb2can", "vector", "socketcan"]
def reverse_bits(value: int, bit_width: int) -> int:
"""按指定位宽整体比特反转(用于refIn/refOut)"""
result = 0
for _ in range(bit_width):
result = (result << 1) | (value & 1)
value >>= 1
return result
def calc_crc(data: bytes, width: int, poly: int, init: int, xorout: int, refin: bool, refout: bool) -> int:
"""标准逐位CRC计算,refIn/refOut可独立配置"""
mask = (1 << width) - 1
crc = init & mask
for b in data:
byte = b
if refin:
byte = reverse_bits(byte, 8)
crc ^= (byte << (width - 8))
for _ in range(8):
if crc & (1 << (width - 1)):
crc = ((crc << 1) ^ poly) & mask
else:
crc = (crc << 1) & mask
if refout:
crc = reverse_bits(crc, width)
crc = (crc ^ xorout) & mask
return crc
# 预置CRC标准参数表
CRC_PRESET = {
"crc8": {"poly": 0x07, "init": 0x00, "xorOut": 0x00, "refIn": False, "refOut": False},
"crc16": {"poly": 0x8005, "init": 0x0000, "xorOut": 0x0000, "refIn": False, "refOut": False},
"crc16-modbus": {"poly": 0x8005, "init": 0xFFFF, "xorOut": 0x0000, "refIn": True, "refOut": True},
"crc16-ccitt": {"poly": 0x1021, "init": 0xFFFF, "xorOut": 0x0000, "refIn": False, "refOut": False},
}
class VirtualJoystickSim:
def __init__(self, root):
self.root = root
self.root.title("多轴虚拟手柄模拟器")
self.root.geometry("1100x760")
# 手柄全局参数
self.axis_count = tk.IntVar(value=2)
self.mode = tk.StringVar(value="abs")
self.sensitivity = tk.DoubleVar(value=1.0)
self.noise_amp = tk.DoubleVar(value=0.2)
self.abs_min = tk.DoubleVar(value=-100.0)
self.abs_max = tk.DoubleVar(value=100.0)
self.axis_val = []
self.axis_frame_list = []
# 各轴独立配置列表(各元素为 tk.DoubleVar,refresh_axis_ui 中创建并维护)
self.axis_sensitivity = []
self.axis_noise_amp = []
self.axis_abs_min = []
self.axis_abs_max = []
# 持久化加载的独立配置(refresh_axis_ui 创建控件后应用)
self._loaded_per_axis = None
# 长按状态(轴号 -> 是否持续移动中)
self._axis_hold = {}
# 相对位移模式:待发送增量缓冲(轴号 -> 周期内累积增量,随发送周期发出并清零)
self.rel_pending = {}
# 指令导入模式(CSV回放):原始数据与列选择
self.csv_raw_headers = []
self.csv_raw_data = []
self.csv_rows = [] # 有效指令序列 [(t_ms, {轴索引: 值, ...}), ...],按时间排序
self.csv_axis_col_vars = [] # 每轴一个值列下拉StringVar,轴i绑定列名(空=该轴不回放)
self.var_csv_time_col = tk.StringVar()
self.var_csv_run = tk.BooleanVar(value=False)
self.csv_status = tk.StringVar(value="未导入CSV文件")
# CSV回放运行状态(主线程after链调度,与周期发送互斥)
self._csv_run = False
self._csv_after_id = None
self._csv_params = None # (周期ms) 启动时快照
self._csv_start_mono = 0.0
self._csv_idx = 0
self._csv_cur_vals = [] # 当前生效的各轴值列表(按选中值列顺序=轴序)
# 串口通信
self.ser = None
self.port_list = []
self.com_port = tk.StringVar()
self.baud = tk.StringVar(value="115200")
self.databit = tk.StringVar(value="8")
self.stopbit = tk.StringVar(value="1")
self.parity = tk.StringVar(value="N无")
self.comm_type = tk.StringVar(value="CDC")
# 收发日志开关:仅通道打开期间记录日志
self._comm_enabled = False
# 日志打印配置项(UI开关,持久化;普通属性镜像供子线程读取)
self.var_log_enabled = tk.BooleanVar(value=True)
self._log_enabled = True
# 日志消息队列:子线程仅put(线程安全),主线程after定时批量写入Text
self._log_queue = queue.Queue()
self._log_drain_after = None
# CAN通道
self.can_bus = None
self.can_mode = tk.StringVar(value="CAN2.0")
self.can_dlc = tk.StringVar(value="8")
self.can_interface = tk.StringVar(value="pcan")
self.can_chan = tk.StringVar(value="0")
self.can_txid = tk.StringVar(value="7FF")
self.can_arb_baud = tk.StringVar(value="500000")
self.can_fd_baud = tk.StringVar(value="2000000")
self.can_ext = tk.BooleanVar(value=False)
# USB-HID通道
self.usb_dev = None
self.usb_vid = tk.StringVar(value="1234")
self.usb_pid = tk.StringVar(value="5678")
self.usb_report_len = tk.IntVar(value=64)
# 接收线程(按当前通道启动对应接收循环)
self.recv_run = False
self.recv_thread = None
# 通信参数持久化
self.comm_cfg = {}
self.load_comm_cfg()
# 周期发送
self.var_cyclic_tx = tk.BooleanVar(value=False)
self.var_tx_period_ms = tk.StringVar(value="100")
self._cyclic_run = False
self._cyclic_after_id = None
self._cyclic_params = None
# 全局字节序(普通业务字段使用)
self.endian = tk.StringVar(value="little")
# 协议存储
self.protocol_fields = []
self.field_tree = None
# 手柄页数据包预览控件(与协议页预览同步刷新)
self.handle_preview = None
# 协议配置模板(多配置保存/加载)
self.configs = []
self.cfg_combo = None
self.cfg_name = tk.StringVar()
self.last_config_name = None
# 新增字段顶层变量
self.field_class = tk.StringVar(value="const")
self.field_name = tk.StringVar()
self.field_byte_len = tk.IntVar(value=1)
# 1 常量参数
self.const_hex = tk.StringVar(value="00")
# 2 轴绑定参数
self.bind_axis_sel = tk.StringVar(value="1")
self.axis_encode_type = tk.StringVar(value="float32(4字节浮点)")
# 自定义表达式参数(编码格式为custom时使用;输出格式由表达式结果类型决定)
self.axis_expr = tk.StringVar(value="int(value*32768)")
# 3 CRC全套参数
self.crc_model_std = tk.StringVar(value="crc16-modbus")
self.crc_poly = tk.StringVar(value="0x8005")
self.crc_init = tk.StringVar(value="0xFFFF")
self.crc_xor_out = tk.StringVar(value="0x0000")
self.crc_ref_in = tk.BooleanVar(value=True)
self.crc_ref_out = tk.BooleanVar(value=True)
self.crc_start_off = tk.IntVar(value=0)
self.crc_data_len = tk.IntVar(value=0)
self.crc_out_endian = tk.StringVar(value="little")
# CRC界面控件缓存(用于锁定/解锁)
self.crc_widgets = {}
self.build_ui()
self.refresh_com_port()
self.refresh_axis_ui()
self.sync_crc_widget_state()
self.load_configs()
self.refresh_preview() # 启动即显示数据包预览
def build_ui(self):
notebook = ttk.Notebook(self.root)
notebook.pack(fill="both", expand=True, padx=6, pady=6)
tab_handle = ttk.Frame(notebook)
tab_protocol = ttk.Frame(notebook)
tab_comm = ttk.Frame(notebook)
tab_log = ttk.Frame(notebook)
notebook.add(tab_handle, text="手柄控制")
notebook.add(tab_protocol, text="私有协议编辑")
notebook.add(tab_comm, text="收发控制台")
notebook.add(tab_log, text="收发日志")
self.build_handle_tab(tab_handle)
self.build_protocol_tab(tab_protocol)
self.build_comm_tab(tab_comm)
self.build_log_tab(tab_log)
# ========== 手柄页面 ==========
def build_handle_tab(self, parent):
cfg_frame = ttk.LabelFrame(parent, text="手柄全局配置")
cfg_frame.pack(fill="x", padx=8, pady=4)
ttk.Label(cfg_frame, text="轴数量(1-8):").grid(row=0, column=0, padx=5)
sp_axis = ttk.Spinbox(cfg_frame, from_=1, to=8, textvariable=self.axis_count, width=6)
sp_axis.grid(row=0, column=1)
ttk.Button(cfg_frame, text="更新轴布局", command=self.refresh_axis_ui).grid(row=0, column=2, padx=5)
ttk.Label(cfg_frame, text="工作模式:").grid(row=0, column=3, padx=10)
ttk.Radiobutton(cfg_frame, text="绝对位置", variable=self.mode, value="abs", command=self.on_mode_change).grid(row=0, column=4)
ttk.Radiobutton(cfg_frame, text="相对位移", variable=self.mode, value="rel", command=self.on_mode_change).grid(row=0, column=5)
# 指令导入模式:CSV回放,按时间轴与发送周期节拍组包发送
csv_frame = ttk.LabelFrame(parent, text="指令导入模式(CSV回放)")
csv_frame.pack(fill="x", padx=8, pady=4)
row0 = ttk.Frame(csv_frame)
row0.pack(fill="x", padx=4, pady=2)
ttk.Button(row0, text="导入CSV文件", command=self.import_csv_file).pack(side="left", padx=4)
ttk.Label(row0, textvariable=self.csv_status, foreground="#444444").pack(side="left", padx=6)
row1 = ttk.Frame(csv_frame)
row1.pack(fill="x", padx=4, pady=2)
ttk.Label(row1, text="值列绑定(每行一个轴号,留空=该轴不回放):").pack(side="left", padx=4, anchor="n")
self.csv_bind_frame = ttk.Frame(row1)
self.csv_bind_frame.pack(side="left", padx=2, fill="x", expand=True)
row2 = ttk.Frame(csv_frame)
row2.pack(fill="x", padx=4, pady=2)
ttk.Label(row2, text="时间列(ms):").pack(side="left", padx=4)
self.cb_csv_time = ttk.Combobox(row2, textvariable=self.var_csv_time_col, state="readonly", width=10)
self.cb_csv_time.pack(side="left", padx=2)
self.cb_csv_time.bind("<<ComboboxSelected>>", self.on_csv_col_change)
ttk.Checkbutton(row2, text="开始回放", variable=self.var_csv_run,
command=self.toggle_csv_playback).pack(side="left", padx=10)
ttk.Label(row2, text="值按绝对位置回放;发送节拍取收发控制台发送周期,时间轴到达即更新值", foreground="#777777").pack(side="left", padx=6)
self._rebuild_csv_axis_binds()
self.axis_main_frame = ttk.LabelFrame(parent, text="各轴控制区 ◀负向 ▶正向(长按持续移动)")
self.axis_main_frame.pack(fill="both", expand=True, padx=8, pady=4)
# 手柄页数据包实时预览
preview_frame = ttk.LabelFrame(parent, text="数据包实时预览")
preview_frame.pack(fill="x", padx=8, pady=4)
self.handle_preview = tk.Text(preview_frame, height=3)
self.handle_preview.pack(fill="x")
def refresh_axis_ui(self):
for frame in self.axis_frame_list:
frame.destroy()
self.axis_frame_list.clear()
self.axis_val.clear()
self.axis_sensitivity.clear()
self.axis_noise_amp.clear()
self.axis_abs_min.clear()
self.axis_abs_max.clear()
self._axis_hold = {}
self.rel_pending = {}
self.save_comm_cfg() # 轴数量等手柄参数持久化
ax_num = self.axis_count.get()
for i in range(ax_num):
self.axis_val.append(0.0)
frame = ttk.Frame(self.axis_main_frame)
frame.pack(fill="x", pady=3)
self.axis_frame_list.append(frame)
ttk.Label(frame, text=f"轴{i+1}:").grid(row=0, column=0, padx=5)
val_label = ttk.Label(frame, text="0.0", width=24)
setattr(self, f"ax_label_{i}", val_label)
val_label.grid(row=0, column=1, padx=5)
btn_left = ttk.Button(frame, text="◀")
btn_left.grid(row=0, column=2, padx=8)
btn_left.bind("<ButtonPress-1>", lambda e, idx=i: self.axis_hold_start(idx, -1))
btn_left.bind("<ButtonRelease-1>", lambda e, idx=i: self.axis_hold_stop(idx))
btn_right = ttk.Button(frame, text="▶")
btn_right.grid(row=0, column=3, padx=8)
btn_right.bind("<ButtonPress-1>", lambda e, idx=i: self.axis_hold_start(idx, 1))
btn_right.bind("<ButtonRelease-1>", lambda e, idx=i: self.axis_hold_stop(idx))
# 各轴独立配置行(row=1):灵敏度、噪声、下限、上限
cfg_row = ttk.Frame(frame)
cfg_row.grid(row=1, column=0, columnspan=4, sticky="ew", padx=(28, 0), pady=(0, 2))
ttk.Label(cfg_row, text="灵敏度:").pack(side="left")
sens_v = tk.DoubleVar(value=self.sensitivity.get())
self.axis_sensitivity.append(sens_v)
ttk.Entry(cfg_row, textvariable=sens_v, width=6).pack(side="left", padx=1)
ttk.Label(cfg_row, text="噪声:").pack(side="left", padx=(4, 0))
noise_v = tk.DoubleVar(value=self.noise_amp.get())
self.axis_noise_amp.append(noise_v)
ttk.Entry(cfg_row, textvariable=noise_v, width=6).pack(side="left", padx=1)
ttk.Label(cfg_row, text="下限:").pack(side="left", padx=(4, 0))
min_v = tk.DoubleVar(value=self.abs_min.get())
self.axis_abs_min.append(min_v)
ttk.Entry(cfg_row, textvariable=min_v, width=6).pack(side="left", padx=1)
ttk.Label(cfg_row, text="上限:").pack(side="left", padx=(4, 0))
max_v = tk.DoubleVar(value=self.abs_max.get())
self.axis_abs_max.append(max_v)
ttk.Entry(cfg_row, textvariable=max_v, width=6).pack(side="left", padx=1)
# 应用持久化加载的独立配置
if self._loaded_per_axis:
for i, cfg in enumerate(self._loaded_per_axis):
if i < len(self.axis_sensitivity):
try:
self.axis_sensitivity[i].set(float(cfg.get("sensitivity", self.sensitivity.get())))
except Exception:
pass
try:
self.axis_noise_amp[i].set(float(cfg.get("noise_amp", self.noise_amp.get())))
except Exception:
pass
try:
self.axis_abs_min[i].set(float(cfg.get("abs_min", self.abs_min.get())))
except Exception:
pass
try:
self.axis_abs_max[i].set(float(cfg.get("abs_max", self.abs_max.get())))
except Exception:
pass
self._rebuild_csv_axis_binds() # CSV回放值列绑定行随轴数重建(保留已选列)
def axis_hold_start(self, axis_idx, direction):
if self._axis_hold.get(axis_idx, False):
return
self._axis_hold[axis_idx] = True
self.axis_move(axis_idx, direction)
self.root.after(HOLD_TICK_MS, lambda: self._axis_hold_tick(axis_idx, direction))
def _axis_hold_tick(self, axis_idx, direction):
if not self._axis_hold.get(axis_idx, False):
return
self.axis_move(axis_idx, direction)
self.root.after(HOLD_TICK_MS, lambda: self._axis_hold_tick(axis_idx, direction))
def axis_hold_stop(self, axis_idx):
self._axis_hold[axis_idx] = False
def add_noise(self, val, axis_idx=None):
amp = self.axis_noise_amp[axis_idx].get() if axis_idx is not None and axis_idx < len(self.axis_noise_amp) else self.noise_amp.get()
return val + np.random.normal(0, amp)
def axis_move(self, axis_idx, direction):
sens = self.axis_sensitivity[axis_idx].get() if axis_idx < len(self.axis_sensitivity) else self.sensitivity.get()
delta = direction * sens
ax_id = axis_idx + 1
if self.mode.get() == "abs":
new_noise = self.add_noise(self.axis_val[axis_idx] + delta, axis_idx)
min_v = self.axis_abs_min[axis_idx].get() if axis_idx < len(self.axis_abs_min) else self.abs_min.get()
max_v = self.axis_abs_max[axis_idx].get() if axis_idx < len(self.axis_abs_max) else self.abs_max.get()
final_pos = np.clip(new_noise, min_v, max_v)
self.axis_val[axis_idx] = final_pos
sig = {"mode": "absolute", "axis": ax_id, "value": final_pos}
else:
delta_noise = self.add_noise(delta, axis_idx)
self.axis_val[axis_idx] += delta_noise
sig = {"mode": "relative", "axis": ax_id, "delta": delta_noise}
# 相对位移:增量累积到待发送缓冲,周期发送开启时由周期线程统一发出,
# 保证步幅与发送周期绑定(每帧=周期内累积增量),避免两种发送节奏增量不一致
self.rel_pending[ax_id] = self.rel_pending.get(ax_id, 0.0) + delta_noise
if self.var_cyclic_tx.get():
sig = None
label = getattr(self, f"ax_label_{axis_idx}")
label.config(text=f"{self.axis_val[axis_idx]:.17g}")
if sig is not None:
self.write_log(f"原始手柄信号:{sig}")
self.refresh_preview()
def on_mode_change(self):
# 切换工作模式时清零相对位移累积增量,避免残留到新的发送会话
self.rel_pending = {}
self.save_comm_cfg() # 工作模式持久化
# ========== 指令导入模式(CSV回放) ==========
def import_csv_file(self):
path = filedialog.askopenfilename(filetypes=[("CSV文件", "*.csv"), ("所有文件", "*.*")])
if not path:
return
try:
headers, data = self._read_csv(path)
except Exception as e:
messagebox.showerror("CSV导入失败", str(e))
return
self.csv_raw_headers = headers
self.csv_raw_data = data
# 时间列默认:采样前50行,首个数值单调不减的列;值列默认:轴1绑非时间列首列
time_idx = self._default_time_col(headers, data)
self.var_csv_time_col.set(headers[time_idx])
default_val = headers[0] if time_idx != 0 else (headers[1] if len(headers) > 1 else headers[0])
for i, var in enumerate(self.csv_axis_col_vars):
var.set(default_val if i == 0 else "")
if i < len(self.csv_axis_cbs):
self.csv_axis_cbs[i]["values"] = headers
self.cb_csv_time["values"] = headers
self._rebuild_csv_rows()
self.csv_status.set(f"{os.path.basename(path)}:有效指令 {len(self.csv_rows)} 条")
@staticmethod
def _default_time_col(headers, data):
# 时间列特征:数值且单调不减;无匹配时回退第1列
for ci in range(len(headers)):
prev = None
ok = True
for ln in data[:50]:
if ci >= len(ln):
continue
try:
v = float(ln[ci])
except ValueError:
ok = False
break
if prev is not None and v < prev:
ok = False
break
prev = v
if ok and prev is not None:
return ci
return 0
@staticmethod
def _read_csv(path):
"""读取CSV:第一行全部为数值视为数据行,否则视为表头;返回(表头列表, 数据行列表)"""
with open(path, "r", encoding="utf-8-sig", newline="") as f:
lines = [ln for ln in csv.reader(f) if any(c.strip() for c in ln)]
if not lines:
raise ValueError("CSV文件为空")
def all_numeric(line):
for c in line:
try:
float(c)
except ValueError:
return False
return True
if all_numeric(lines[0]):
headers = [f"列{i}" for i in range(len(lines[0]))]
data = lines
else:
headers = lines[0]
data = lines[1:]
return headers, data
def on_csv_col_change(self, event=None):
self._rebuild_csv_rows()
self.csv_status.set(f"有效指令 {len(self.csv_rows)} 条")
def _rebuild_csv_axis_binds(self):
# 每轴一行值列下拉(两列grid布局),轴数变化时重建并保留已选列
old = [v.get() for v in self.csv_axis_col_vars]
for w in self.csv_bind_frame.winfo_children():
w.destroy()
self.csv_axis_col_vars = []
self.csv_axis_cbs = []
n = self.axis_count.get()
for i in range(n):
var = tk.StringVar(value=old[i] if i < len(old) else "")
self.csv_axis_col_vars.append(var)
cell = ttk.Frame(self.csv_bind_frame)
cell.grid(row=i // 2, column=(i % 2) * 2, sticky="w", padx=(0, 6), pady=1)
ttk.Label(cell, text=f"轴{i + 1}:").pack(side="left")
cb = ttk.Combobox(cell, textvariable=var, state="readonly", width=10,
values=self.csv_raw_headers)
cb.pack(side="left", padx=2)
cb.bind("<<ComboboxSelected>>", self.on_csv_col_change)
self.csv_axis_cbs.append(cb)
def _rebuild_csv_rows(self):
# 按每轴绑定的值列/时间列重建指令序列(float解析容错,非法行跳过,按时间排序)
rows = []
try:
t_idx = self.csv_raw_headers.index(self.var_csv_time_col.get())
except ValueError:
self.csv_rows = []
return
# 轴索引(0基) → CSV列索引;未绑定列名的轴不参与回放
binds = {}
for i, var in enumerate(self.csv_axis_col_vars):
name = var.get()
if name and name in self.csv_raw_headers:
binds[i] = self.csv_raw_headers.index(name)
if not binds:
self.csv_rows = []
return
for ln in self.csv_raw_data:
if t_idx >= len(ln) or any(c >= len(ln) for c in binds.values()):
continue
try:
d = {i: float(ln[c]) for i, c in binds.items()}
rows.append((float(ln[t_idx]), d))
except ValueError:
continue
if not rows:
self.csv_rows = []
return
# 时间单位自动推断:最大值<100视为秒(如0~2s的4000行数据),转毫秒,避免全部指令一次被消费
max_t = max(r[0] for r in rows)
if max_t < 100:
rows = [(t * 1000.0, d) for t, d in rows]
self.write_log(f"【指令仿真】时间列识别为秒单位(最大{max_t:g}s),已自动转为毫秒({max_t * 1000:g}ms)")
rows.sort(key=lambda r: r[0])
self.csv_rows = rows
def _csv_elapsed_ms(self):
return (time.monotonic() - self._csv_start_mono) * 1000.0
def toggle_csv_playback(self):
want_run = self.var_csv_run.get()
if want_run:
if not self.csv_rows:
messagebox.showwarning("提示", "请先导入CSV并选择值列/时间列")
self.var_csv_run.set(False)
return
if not self._comm_enabled or not self.is_current_open():
messagebox.showwarning("提示", "请先打开通信通道")
self.var_csv_run.set(False)
return
try:
period = int(self.var_tx_period_ms.get())
if period < 1:
raise ValueError
except ValueError:
messagebox.showerror("参数错误", "发送周期必须为大于0的整数ms(收发控制台配置)")
self.var_csv_run.set(False)
return
if self._cyclic_run:
# 与周期发送互斥:先停周期发送
self._cyclic_run = False
if self._cyclic_after_id is not None:
try:
self.root.after_cancel(self._cyclic_after_id)
except Exception:
pass
self._cyclic_after_id = None
self.var_cyclic_tx.set(False)
first = self.csv_rows[0][1]
for i, v in first.items():
if i < len(self.axis_val):
min_v = self.axis_abs_min[i].get() if i < len(self.axis_abs_min) else self.abs_min.get()
max_v = self.axis_abs_max[i].get() if i < len(self.axis_abs_max) else self.abs_max.get()
self.axis_val[i] = float(np.clip(v, min_v, max_v))
axis_dict = {i + 1: self.axis_val[i] for i in range(len(self.axis_val))}
pkt_first = self.build_protocol_packet(axis_dict)
if pkt_first:
self.write_log(f"【指令仿真】首条指令 hex:{pkt_first.hex(' ').upper()}")
self.transmit_raw(pkt_first)
self._csv_params = (period,)
self._csv_run = True
self._csv_idx = 1
self._csv_cur_vals = {i: self.axis_val[i] for i in first if i < len(self.axis_val)}
self._csv_start_mono = time.monotonic()
self.write_log(f"【指令仿真】开始回放 {len(self.csv_rows)} 条指令,周期{period}ms,控制{len(self._csv_cur_vals)}个轴")
self._csv_after_id = self.root.after(period, self._csv_tick)
else:
self.stop_csv_playback()
self.write_log("【指令仿真】已停止")
def _csv_tick(self):
self._csv_after_id = None
if not self._csv_run:
self.var_csv_run.set(False)
return
if not self._comm_enabled or not self.is_current_open():
self._csv_run = False
self.var_csv_run.set(False)
self.write_log("【指令仿真】通道异常,已停止")
return
period = self._csv_params[0]
# 按时间轴消费已到期的指令(非逐行发送:一个节拍内可能跳过/合并多条)
elapsed_ms = self._csv_elapsed_ms()
rows = self.csv_rows
n = len(rows)
while self._csv_idx < n and rows[self._csv_idx][0] <= elapsed_ms:
self._csv_cur_vals = rows[self._csv_idx][1]
self._csv_idx += 1
# 各轴当前值作为绝对位置,一次组包同时下发:未绑定轴保持当前值,超出轴数量部分忽略
axis_dict = {i + 1: self.axis_val[i] for i in range(len(self.axis_val))}
parts = []
for i, v in sorted(self._csv_cur_vals.items()):
if i >= len(self.axis_val):
continue
min_v = self.axis_abs_min[i].get() if i < len(self.axis_abs_min) else self.abs_min.get()
max_v = self.axis_abs_max[i].get() if i < len(self.axis_abs_max) else self.abs_max.get()
cv = float(np.clip(v, min_v, max_v))
self.axis_val[i] = cv
axis_dict[i + 1] = cv
parts.append(f"轴{i + 1}={cv:.17g}")
self.write_log(f"【指令仿真】t={elapsed_ms:.0f}ms " + " ".join(parts))
pkt = self.build_protocol_packet(axis_dict)
if pkt:
self.write_log(f"【组包下发 十六进制】{pkt.hex(' ').upper()}")
self.transmit_raw(pkt)
self.refresh_preview()
if self._csv_idx >= n:
self._csv_run = False
self.var_csv_run.set(False)
self.write_log(f"【指令仿真】回放完成,共{n}条指令")
return
self._csv_after_id = self.root.after(period, self._csv_tick)
def stop_csv_playback(self):
# 停止指令回放(幂等,通道关闭/窗口关闭时调用)
self._csv_run = False
if self._csv_after_id is not None:
try:
self.root.after_cancel(self._csv_after_id)
except Exception:
pass
self._csv_after_id = None
# ========== CRC控件锁定状态同步 ==========
def sync_crc_widget_state(self, *args):
mod = self.crc_model_std.get()
is_preset = mod in CRC_PRESET
state = "disabled" if is_preset else "normal"
for w in self.crc_widgets.values():
w.config(state=state)
def on_crc_preset_change(self, *args):
mod = self.crc_model_std.get()
self.sync_crc_widget_state()
if mod not in CRC_PRESET:
return
cfg = CRC_PRESET[mod]
self.crc_poly.set(f"0x{cfg['poly']:X}")
self.crc_init.set(f"0x{cfg['init']:X}")
self.crc_xor_out.set(f"0x{cfg['xorOut']:X}")
self.crc_ref_in.set(cfg["refIn"])
self.crc_ref_out.set(cfg["refOut"])
# 预置模板自动设置CRC输出字节序
if mod == "crc16-modbus":
self.crc_out_endian.set("little")
else:
self.crc_out_endian.set("big")
# ========== 协议编辑核心页面 ==========
def build_protocol_tab(self, parent):
# 协议配置模板管理(多配置,保存为json,启动自动导入)
cfg_frame = ttk.LabelFrame(parent, text="协议配置模板(双击字段可编辑)")
cfg_frame.pack(fill="x", padx=6, pady=4)
ttk.Label(cfg_frame, text="选择配置:").grid(row=0, column=0, padx=4)
self.cfg_combo = ttk.Combobox(cfg_frame, textvariable=self.cfg_name, width=24)
self.cfg_combo.grid(row=0, column=1, padx=4)
ttk.Button(cfg_frame, text="保存为配置", command=self.save_current_config).grid(row=0, column=2, padx=4)
ttk.Button(cfg_frame, text="新建配置", command=self.new_config).grid(row=0, column=3, padx=4)
ttk.Button(cfg_frame, text="删除配置", command=self.del_config).grid(row=0, column=4, padx=4)
ttk.Button(cfg_frame, text="导入配置", command=self.import_config).grid(row=0, column=5, padx=4)
# 全局字节序
endian_frame = ttk.LabelFrame(parent, text="全局字节序:小端低字节在前 / 大端高字节在前")
endian_frame.pack(fill="x", padx=6, pady=4)
ttk.Label(endian_frame, text="字节序选择:").grid(row=0, column=0, padx=4)
ttk.Combobox(endian_frame, textvariable=self.endian, values=["little", "big"], width=10).grid(row=0, column=1)
# 新增字段总配置
field_cfg = ttk.LabelFrame(parent, text="新增协议字段(自动分配字节位置,仅填写长度)")
field_cfg.pack(fill="x", padx=6, pady=4)
ttk.Label(field_cfg, text="字段大类:").grid(row=0, column=0, padx=4)
cb_class = ttk.Combobox(field_cfg, textvariable=self.field_class, width=16, values=["const", "axis", "crc"])
cb_class.grid(row=0, column=1)
cb_class.bind("<<ComboboxSelected>>", self.switch_field_ui)
self.crc_model_std.trace_add("write", self.on_crc_preset_change)
ttk.Label(field_cfg, text="字段名称:").grid(row=0, column=2, padx=8)
ttk.Entry(field_cfg, textvariable=self.field_name, width=12).grid(row=0, column=3)
ttk.Label(field_cfg, text="占用字节长度:").grid(row=0, column=4, padx=8)
ttk.Entry(field_cfg, textvariable=self.field_byte_len, width=6).grid(row=0, column=5)
self.sub_param_frame = ttk.Frame(field_cfg)
self.sub_param_frame.grid(row=1, column=0, columnspan=12, pady=8)
self.switch_field_ui()
btn_frame = ttk.Frame(field_cfg)
btn_frame.grid(row=2, column=0, columnspan=12, pady=4)
ttk.Button(btn_frame, text="添加此字段", command=self.add_protocol_field).grid(row=0, column=0, padx=6)
ttk.Button(btn_frame, text="删除选中", command=self.del_protocol_field).grid(row=0, column=1, padx=6)
ttk.Button(btn_frame, text="清空全部协议", command=self.clear_all_protocol).grid(row=0, column=2, padx=6)
# 协议表格
table_frame = ttk.LabelFrame(parent, text="协议字段列表(从上至下依次拼接)")
table_frame.pack(fill="both", expand=True, padx=6, pady=4)
self.field_tree = ttk.Treeview(table_frame, columns=("pos", "name", "class", "len", "desc"), show="headings")
self.field_tree.heading("pos", text="起始偏移")
self.field_tree.heading("name", text="字段名")
self.field_tree.heading("class", text="类型")
self.field_tree.heading("len", text="字节长")
self.field_tree.heading("desc", text="参数说明")
self.field_tree.pack(fill="both", expand=True)
self.field_tree.bind("<Double-1>", self.on_field_double_click)
# 预览
preview_frame = ttk.LabelFrame(parent, text="数据包预览(使用全部轴实时当前值组包)")
preview_frame.pack(fill="x", padx=6, pady=4)
self.proto_preview = tk.Text(preview_frame, height=3)
self.proto_preview.pack(fill="x")
# ========== 协议配置模板管理 ==========
def load_configs(self):
self.configs = []
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
self.configs = data.get("configs", [])
self.last_config_name = data.get("last_used")
except Exception as e:
self.write_log(f"[配置加载失败] {e}")
self.configs = []
self.refresh_config_combo()
if self.configs and self.last_config_name:
self.apply_config(self.last_config_name)
def save_configs(self):
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump({"configs": self.configs, "last_used": self.last_config_name}, f, ensure_ascii=False, indent=2)
def refresh_config_combo(self):
if self.cfg_combo:
self.cfg_combo["values"] = [c["name"] for c in self.configs]
def apply_config(self, name):
for c in self.configs:
if c["name"] == name:
self.protocol_fields = [dict(fd) for fd in c["fields"]]
self.endian.set(c.get("endian", "little"))
self.cfg_name.set(name)
self.last_config_name = name
self.refresh_protocol_tree()
self.refresh_preview()
self.write_log(f"[协议配置] 已载入配置: {name}({len(self.protocol_fields)}个字段)")
return
def save_current_config(self):
name = self.cfg_name.get().strip()
if not name:
messagebox.showwarning("提示", "请先在配置名中输入名称,或从下拉框选择要覆盖的配置")
return
if not self.protocol_fields:
messagebox.showwarning("提示", "当前没有协议字段,无可保存内容")
return
cfg = {"name": name, "endian": self.endian.get(),
"fields": [dict(fd) for fd in self.protocol_fields]}
for idx, c in enumerate(self.configs):
if c["name"] == name:
self.configs[idx] = cfg
break
else:
self.configs.append(cfg)
self.last_config_name = name
self.save_configs()
self.refresh_config_combo()
self.cfg_name.set(name)
messagebox.showinfo("成功", f"配置[{name}]已保存到{CONFIG_FILE}")
def new_config(self):
dlg = tk.Toplevel(self.root)
dlg.title("新建协议配置")
dlg.geometry("340x130")
dlg.transient(self.root)
dlg.grab_set()
var_name = tk.StringVar()
ttk.Label(dlg, text="配置名称:").grid(row=0, column=0, padx=10, pady=12, sticky="w")
entry = ttk.Entry(dlg, textvariable=var_name)
entry.grid(row=0, column=1, padx=10, pady=12, sticky="ew")
entry.focus()
def confirm():
name = var_name.get().strip()
if not name:
messagebox.showwarning("提示", "配置名称不能为空", parent=dlg)
return
if any(c["name"] == name for c in self.configs):
messagebox.showwarning("提示", f"配置[{name}]已存在,请换一个名称", parent=dlg)
return
self.protocol_fields.clear()
self.refresh_protocol_tree()
self.refresh_preview()
self.cfg_name.set(name)
dlg.destroy()
ttk.Button(dlg, text="确定", command=confirm).grid(row=1, column=0, columnspan=2, pady=5)
dlg.bind("<Return>", lambda e: confirm())
def del_config(self):
name = self.cfg_name.get().strip()
if not name:
messagebox.showwarning("提示", "请先选择要删除的配置")
return
self.configs = [c for c in self.configs if c["name"] != name]
self.last_config_name = None
self.save_configs()
self.refresh_config_combo()
self.cfg_name.set("")
messagebox.showinfo("完成", f"配置[{name}]已删除")
def apply_imported_config(self, cfg):
# 导入配置直接载入工作区:覆盖当前协议字段与字节序
self.protocol_fields = [dict(fd) for fd in cfg["fields"]]
self.endian.set(cfg.get("endian", "little"))
self.cfg_name.set(cfg["name"])
self.refresh_protocol_tree()
self.refresh_preview()
self.write_log(f"[协议配置] 已导入配置: {cfg['name']}({len(self.protocol_fields)}个字段)")
def import_config(self):
name = self.cfg_name.get().strip()
if not name:
messagebox.showwarning("提示", "请先在下拉框中选择要导入的配置")
return
for c in self.configs:
if c["name"] == name:
self.apply_imported_config(c)
return
messagebox.showwarning("提示", f"配置[{name}]不在模板列表中")
def switch_field_ui(self, event=None):
for widget in self.sub_param_frame.winfo_children():
widget.destroy()
self.crc_widgets.clear()
cls = self.field_class.get()
if cls == "const":
ttk.Label(self.sub_param_frame, text="常量十六进制值:").grid(row=0, column=0, padx=4)
ttk.Entry(self.sub_param_frame, textvariable=self.const_hex, width=24).grid(row=0, column=1)
elif cls == "axis":
ttk.Label(self.sub_param_frame, text="绑定目标轴:").grid(row=0, column=0, padx=4)
ax_comb = ttk.Combobox(self.sub_param_frame, textvariable=self.bind_axis_sel, width=8, values=["1", "2", "3", "4", "5", "6", "7", "8"])
ax_comb.grid(row=0, column=1)
ttk.Label(self.sub_param_frame, text="编码格式:").grid(row=0, column=2, padx=8)
enc_comb = ttk.Combobox(self.sub_param_frame, textvariable=self.axis_encode_type, width=20, values=["float32(4字节浮点)", "int16(2字节映射整型)", "int32(4字节映射整型)", "Q15(2字节定点数)", "Q16(4字节定点数)", "Q30(4字节定点数)", "Q31(4字节定点数)", "custom(自定义表达式)"])
enc_comb.grid(row=0, column=3)
enc_comb.bind("<<ComboboxSelected>>", self.on_axis_encode_change)
# 自定义表达式行:仅编码格式为custom时显示
self.expr_frame = ttk.Frame(self.sub_param_frame)
self.expr_frame.grid(row=1, column=0, columnspan=12, sticky="w", pady=4)
ttk.Label(self.expr_frame, text="转换表达式:").pack(side="left")
ttk.Entry(self.expr_frame, textvariable=self.axis_expr, width=26).pack(side="left", padx=4)
ttk.Label(self.expr_frame, text="输出格式由表达式结果决定:int/round→整型(2或4字节),float/四则→浮点(4字节)。示例: int(value*32768) | round(value*100) | float(value)/2", foreground="#777777").pack(side="left", padx=6)
self.on_axis_encode_change()
elif cls == "crc":
row0 = ttk.Frame(self.sub_param_frame)
row0.grid(row=0, column=0, columnspan=10, sticky="w")
ttk.Label(row0, text="预置CRC模型:").grid(row=0, column=0, padx=4)
crc_cbx = ttk.Combobox(row0, textvariable=self.crc_model_std, width=14, values=["crc8", "crc16", "crc16-modbus", "crc16-ccitt", "custom(自定义)"])
crc_cbx.grid(row=0, column=1)
row1 = ttk.Frame(self.sub_param_frame)
row1.grid(row=1, column=0, columnspan=10, sticky="w", pady=4)
ttk.Label(row1, text="多项式(hex):").grid(row=0, column=0, padx=4)
w_poly = ttk.Entry(row1, textvariable=self.crc_poly, width=12)
w_poly.grid(row=0, column=1)
self.crc_widgets["poly"] = w_poly
ttk.Label(row1, text="初始值(hex):").grid(row=0, column=2, padx=8)
w_init = ttk.Entry(row1, textvariable=self.crc_init, width=12)
w_init.grid(row=0, column=3)
self.crc_widgets["init"] = w_init
row2 = ttk.Frame(self.sub_param_frame)
row2.grid(row=2, column=0, columnspan=10, sticky="w", pady=4)
ttk.Label(row2, text="输出异或值(hex):").grid(row=0, column=0, padx=4)
w_xor = ttk.Entry(row2, textvariable=self.crc_xor_out, width=12)
w_xor.grid(row=0, column=1)
self.crc_widgets["xor"] = w_xor
w_refin = ttk.Checkbutton(row2, text="输入位反转", variable=self.crc_ref_in)
w_refin.grid(row=0, column=2, padx=8)
self.crc_widgets["refin"] = w_refin
w_refout = ttk.Checkbutton(row2, text="输出位反转", variable=self.crc_ref_out)
w_refout.grid(row=0, column=3)
self.crc_widgets["refout"] = w_refout
row3 = ttk.Frame(self.sub_param_frame)
row3.grid(row=3, column=0, columnspan=10, sticky="w", pady=4)
ttk.Label(row3, text="校验起始偏移:").grid(row=0, column=0, padx=4)
ttk.Entry(row3, textvariable=self.crc_start_off, width=6).grid(row=0, column=1)
ttk.Label(row3, text="校验数据长度:").grid(row=0, column=2, padx=8)
ttk.Entry(row3, textvariable=self.crc_data_len, width=6).grid(row=0, column=3)
row4 = ttk.Frame(self.sub_param_frame)
row4.grid(row=4, column=0, columnspan=10, sticky="w", pady=4)
ttk.Label(row4, text="CRC输出字节序:").grid(row=0, column=0, padx=4)
cb_crc_out_end = ttk.Combobox(row4, textvariable=self.crc_out_endian, values=["little", "big"], width=10)
cb_crc_out_end.grid(row=0, column=1)
self.sync_crc_widget_state()
def on_axis_encode_change(self, event=None):
# 编码格式切换:仅custom显示表达式输入行
if hasattr(self, "expr_frame"):
try:
if not self.expr_frame.winfo_exists():
return
except tk.TclError:
return
if "custom" in self.axis_encode_type.get():
self.expr_frame.grid()
else:
self.expr_frame.grid_remove()
def calc_all_field_offset(self):
off_list = []
cur = 0
for fd in self.protocol_fields:
off_list.append(cur)
cur += fd["byte_len"]
return off_list
def float_to_mapped_int(self, float_val, encode):
# 浮点最值来自手柄控制页(浮点下限Min/浮点上限Max),整型映射范围按编码类型固定
f_min = self.abs_min.get()
f_max = self.abs_max.get()
if "int16" in encode:
i_min, i_max = -32768, 32767
else:
i_min, i_max = -2147483648, 2147483647
ratio = (float_val - f_min) / (f_max - f_min)
return int(np.clip(round(i_min + ratio * (i_max - i_min)), i_min, i_max))
@staticmethod
def float_to_q(float_val, q_bits):
# Q定点数编码:浮点值×2^N取整,clip到存储位宽(Q15按16位,Q16/Q30/Q31按32位存储)
scale = 1 << q_bits
total_bits = 32 if q_bits >= 16 else q_bits + 1
i_min = -(1 << (total_bits - 1))
i_max = (1 << (total_bits - 1)) - 1
return int(np.clip(round(float_val * scale), i_min, i_max))
@staticmethod
def eval_expr_raw(expr, value):
# 受限表达式求值:仅暴露value变量与白名单函数(int/float为输出类型表达),异常原样抛出
ns = {"value": value, "round": round, "int": int, "float": float, "bool": bool,
"abs": abs, "min": min, "max": max, "floor": math.floor, "ceil": math.ceil,
"sqrt": math.sqrt, "pow": pow, "pi": math.pi}
return eval(expr, {"__builtins__": {}}, ns)
def eval_expr(self, expr, value):
# 运行期求值:异常写日志并返回0,避免组包崩溃
try:
return self.eval_expr_raw(expr, value)
except Exception as e:
self.write_log(f"[表达式错误] {expr} 求值失败: {e},按0处理")
return 0.0
def pack_custom_expr(self, ax_val, fd, endian_char):
# 自定义表达式:float值→表达式转换→按结果类型与字节长度打包(整型clip防溢出)
result = self.eval_expr(fd.get("expr", "value"), ax_val)
blen = fd.get("byte_len", 4)
if isinstance(result, bool):
result = int(result)
if isinstance(result, int):
if blen == 2:
return struct.pack(endian_char + "h", int(np.clip(result, -32768, 32767)))
if blen == 4:
return struct.pack(endian_char + "i", int(np.clip(result, -2147483648, 2147483647)))
self.write_log(f"[表达式错误] 整型结果需2(int16)或4(int32)字节,当前{blen}字节,按0填充")
elif isinstance(result, float):
if blen == 4:
return struct.pack(endian_char + "f", float(result))
self.write_log(f"[表达式错误] 浮点结果必须4字节(float32),当前{blen}字节,按0填充")
else:
self.write_log(f"[表达式错误] 结果类型不合理: {type(result).__name__},按0填充")
return b"\x00" * blen
def make_field_info(self, cls, name, blen, p):
"""根据字段类型和参数构建字段dict(添加/编辑共用),参数非法时抛ValueError"""
fd_info = {"name": name, "class": cls, "byte_len": blen}
if cls == "const":
hex_str = p["const_hex"].strip()
bytes.fromhex(hex_str)
if len(bytes.fromhex(hex_str)) > blen:
raise ValueError(f"常量值{hex_str}超过{blen}字节")
fd_info["const_hex"] = hex_str
desc = f"常量:{hex_str}"
elif cls == "axis":
ax = int(p["bind_ax"])
if not (1 <= ax <= 8):
raise ValueError("轴号范围1~8")
fd_info["bind_ax"] = ax
fd_info["encode"] = p["encode"]
# Q定点数固定字节长度:Q15=2字节,Q16/Q30/Q31=4字节
if "Q15" in p["encode"]:
fd_info["byte_len"] = 2
desc = f"轴{ax} {fd_info['encode']}"
elif any(q in p["encode"] for q in ("Q16", "Q30", "Q31")):
fd_info["byte_len"] = 4
desc = f"轴{ax} {fd_info['encode']}"
elif "custom" in p["encode"]:
# 自定义表达式:校验非空/语法/禁止双下划线,试算结果类型并匹配字节长度
expr = p.get("expr", "").strip()
if not expr:
raise ValueError("自定义表达式不能为空,例如: int(value*32768)")
if "__" in expr:
raise ValueError("表达式不能包含双下划线")
try:
ast.parse(expr, mode="eval")
except SyntaxError:
raise ValueError(f"表达式语法错误: {expr}")
try:
sample = self.eval_expr_raw(expr, 1.0)
except Exception as e:
raise ValueError(f"表达式求值失败(变量名须为value,仅支持白名单函数): {e}")
if isinstance(sample, bool):
sample = int(sample)
if isinstance(sample, int):
if blen not in (2, 4):
raise ValueError("整型表达式字节长度必须为2(int16)或4(int32)")
fd_info["expr_type"] = "int"
elif isinstance(sample, float):
if blen != 4:
raise ValueError("浮点表达式必须使用4字节(float32)")
fd_info["expr_type"] = "float"
else:
raise ValueError(f"表达式结果类型不合理: {type(sample).__name__}")
fd_info["expr"] = expr
desc = f"轴{ax} {fd_info['encode']} [expr: {expr[:24]}{'...' if len(expr) > 24 else ''} | {fd_info['expr_type']}]"
else:
desc = f"轴{ax} {fd_info['encode']}"
elif cls == "crc":
int(p["poly"], 16)
int(p["init"], 16)
int(p["xor_out"], 16)
start = int(p["c_start"])
crc_len = int(p["c_len"])
if crc_len <= 0:
raise ValueError("CRC校验数据长度必须大于0!填写参与CRC运算的字节数(不含CRC自身)")
if start < 0:
raise ValueError("校验起始偏移不能为负数")
fd_info["std_mod"] = p["std_mod"]
fd_info["poly"] = p["poly"]
fd_info["init"] = p["init"]
fd_info["xor_out"] = p["xor_out"]
fd_info["ref_in"] = p["ref_in"]
fd_info["ref_out"] = p["ref_out"]
fd_info["c_start"] = start
fd_info["c_len"] = crc_len
fd_info["crc_out_endian"] = p["crc_out_endian"]
desc = f"{fd_info['std_mod']} 范围{start}~{start + crc_len - 1},输出字节序:{fd_info['crc_out_endian']}"
else:
raise ValueError(f"未知字段类型: {cls}")
fd_info["desc"] = desc
return fd_info
def add_protocol_field(self):
try:
name = self.field_name.get().strip()
if not name:
messagebox.showwarning("提示", "请填写字段名称")
return
blen = int(self.field_byte_len.get())
if blen <= 0:
messagebox.showerror("错误", "字节长度必须大于0")
return
cls = self.field_class.get()
if cls == "const":
p = {"const_hex": self.const_hex.get()}
elif cls == "axis":
p = {"bind_ax": self.bind_axis_sel.get(), "encode": self.axis_encode_type.get()}
if "custom" in p["encode"]:
p["expr"] = self.axis_expr.get()
elif cls == "crc":
p = {"std_mod": self.crc_model_std.get(),
"poly": self.crc_poly.get(), "init": self.crc_init.get(),
"xor_out": self.crc_xor_out.get(),
"ref_in": self.crc_ref_in.get(), "ref_out": self.crc_ref_out.get(),
"c_start": self.crc_start_off.get(), "c_len": self.crc_data_len.get(),
"crc_out_endian": self.crc_out_endian.get()}
else:
messagebox.showerror("错误", f"未知字段类型: {cls}")
return
fd_info = self.make_field_info(cls, name, blen, p)
self.protocol_fields.append(fd_info)
self.refresh_protocol_tree()
self.refresh_preview()
messagebox.showinfo("成功", "字段添加完成")
except ValueError as e:
messagebox.showerror("输入格式错误", f"数字/十六进制非法:{str(e)}")
except Exception as e:
messagebox.showerror("添加字段失败", f"{str(e)}")
def refresh_protocol_tree(self):
for item in self.field_tree.get_children():
self.field_tree.delete(item)
offset_list = self.calc_all_field_offset()
for idx, fd in enumerate(self.protocol_fields):
off = offset_list[idx]
self.field_tree.insert("", "end", values=(off, fd["name"], fd["class"], fd["byte_len"], fd["desc"]))
def del_protocol_field(self):
sel = self.field_tree.selection()
if not sel:
messagebox.showwarning("提示", "请先选中一行字段")
return
idx = self.field_tree.index(sel[0])
del self.protocol_fields[idx]
self.refresh_protocol_tree()
self.refresh_preview()
def clear_all_protocol(self):
self.protocol_fields.clear()
self.refresh_protocol_tree()
self.refresh_preview()
# ========== 双击编辑字段 ==========
def on_field_double_click(self, event):
sel = self.field_tree.selection()
if not sel:
return
idx = self.field_tree.index(sel[0])
self.edit_field_window(idx)
def edit_field_window(self, idx):
field = self.protocol_fields[int(idx)] # field_tree.index()返回str,转int索引列表
cls = field["class"]
win = tk.Toplevel(self.root)
win.title(f"编辑字段 - {field['name']}({cls})")
win.geometry("520x360")
win.transient(self.root)
win.grab_set()
container = ttk.Frame(win)
container.pack(fill="both", expand=True, padx=10, pady=8)
container.columnconfigure(1, weight=1)
v_name = tk.StringVar(value=field["name"])
v_len = tk.IntVar(value=field["byte_len"])
row = 0
ttk.Label(container, text="字段名称:").grid(row=row, column=0, sticky="w", pady=4)
ttk.Entry(container, textvariable=v_name).grid(row=row, column=1, sticky="ew")
row += 1
ttk.Label(container, text="占用字节长度:").grid(row=row, column=0, sticky="w", pady=4)
ttk.Entry(container, textvariable=v_len, width=8).grid(row=row, column=1, sticky="w")
row += 1
sub = ttk.Frame(container)
sub.grid(row=row, column=0, columnspan=2, sticky="ew", pady=6)
fv = {}
crc_widgets_enabled = []
def set_crc_custom_state(enable):
st = "normal" if enable else "disabled"
for w in crc_widgets_enabled:
w.config(state=st)
def apply_preset(*args):
mod = fv.get("std_mod").get()
if mod in CRC_PRESET:
cfg = CRC_PRESET[mod]
fv["poly"].set(f"0x{cfg['poly']:X}")
fv["init"].set(f"0x{cfg['init']:X}")
fv["xor_out"].set(f"0x{cfg['xorOut']:X}")
fv["ref_in"].set(cfg["refIn"])
fv["ref_out"].set(cfg["refOut"])
if mod == "crc16-modbus":
fv["crc_out_endian"].set("little")
else:
fv["crc_out_endian"].set("big")
set_crc_custom_state(False)
else:
set_crc_custom_state(True)
if cls == "const":
fv["const_hex"] = tk.StringVar(value=field.get("const_hex", "00"))
ttk.Label(sub, text="常量十六进制值:").grid(row=0, column=0, padx=4, sticky="w")
ttk.Entry(sub, textvariable=fv["const_hex"], width=24).grid(row=0, column=1, sticky="w")
elif cls == "axis":
fv["bind_ax"] = tk.StringVar(value=str(field.get("bind_ax", 1)))
fv["encode"] = tk.StringVar(value=field.get("encode", "float32(4字节浮点)"))
fv["expr"] = tk.StringVar(value=field.get("expr", "int(value*32768)"))
ttk.Label(sub, text="绑定目标轴:").grid(row=0, column=0, padx=4, sticky="w")
ttk.Combobox(sub, textvariable=fv["bind_ax"], width=6, values=["1", "2", "3", "4", "5", "6", "7", "8"]).grid(row=0, column=1, sticky="w")
ttk.Label(sub, text="编码格式:").grid(row=0, column=2, padx=8, sticky="w")
cb_enc = ttk.Combobox(sub, textvariable=fv["encode"], width=24, values=["float32(4字节浮点)", "int16(2字节映射整型)", "int32(4字节映射整型)", "Q15(2字节定点数)", "Q16(4字节定点数)", "Q30(4字节定点数)", "Q31(4字节定点数)", "custom(自定义表达式)"])
cb_enc.grid(row=0, column=3, sticky="w")
# 自定义表达式行:仅编码格式为custom时显示
expr_frame = ttk.Frame(sub)
expr_frame.grid(row=1, column=0, columnspan=4, sticky="w", pady=4)
ttk.Label(expr_frame, text="转换表达式:").pack(side="left")
ttk.Entry(expr_frame, textvariable=fv["expr"], width=26).pack(side="left", padx=4)
ttk.Label(expr_frame, text="输出格式由表达式结果决定:int/round→整型(2或4字节),float/四则→浮点(4字节)。示例: int(value*32768) | round(value*100) | float(value)/2", foreground="#777777").pack(side="left", padx=6)
def toggle_expr_edit():
if "custom" in fv["encode"].get():
expr_frame.grid()
else:
expr_frame.grid_remove()
cb_enc.bind("<<ComboboxSelected>>", lambda e: toggle_expr_edit())
toggle_expr_edit()
elif cls == "crc":
fv["std_mod"] = tk.StringVar(value=field.get("std_mod", "crc16-modbus"))
fv["poly"] = tk.StringVar(value=field.get("poly", "0x8005"))
fv["init"] = tk.StringVar(value=field.get("init", "0xFFFF"))
fv["xor_out"] = tk.StringVar(value=field.get("xor_out", "0x0000"))
fv["ref_in"] = tk.BooleanVar(value=field.get("ref_in", True))
fv["ref_out"] = tk.BooleanVar(value=field.get("ref_out", True))
fv["c_start"] = tk.IntVar(value=field.get("c_start", 0))
fv["c_len"] = tk.IntVar(value=field.get("c_len", 0))
fv["crc_out_endian"] = tk.StringVar(value=field.get("crc_out_endian", "little"))
ttk.Label(sub, text="预置CRC模型:").grid(row=0, column=0, padx=4, sticky="w")
cb_mod = ttk.Combobox(sub, textvariable=fv["std_mod"], width=16, values=["crc8", "crc16", "crc16-modbus", "crc16-ccitt", "custom(自定义)"])
cb_mod.grid(row=0, column=1, sticky="w")
cb_mod.bind("<<ComboboxSelected>>", apply_preset)
r1 = ttk.Frame(sub)
r1.grid(row=1, column=0, columnspan=4, sticky="w", pady=4)
ttk.Label(r1, text="多项式(hex):").pack(side="left")
w = ttk.Entry(r1, textvariable=fv["poly"], width=10)
w.pack(side="left", padx=4)
crc_widgets_enabled.append(w)
ttk.Label(r1, text="初始值(hex):").pack(side="left")
w = ttk.Entry(r1, textvariable=fv["init"], width=10)
w.pack(side="left", padx=4)
crc_widgets_enabled.append(w)
r2 = ttk.Frame(sub)
r2.grid(row=2, column=0, columnspan=4, sticky="w", pady=4)
ttk.Label(r2, text="输出异或值(hex):").pack(side="left")
w = ttk.Entry(r2, textvariable=fv["xor_out"], width=10)
w.pack(side="left", padx=4)
crc_widgets_enabled.append(w)
w = ttk.Checkbutton(r2, text="输入位反转", variable=fv["ref_in"])
w.pack(side="left", padx=8)
crc_widgets_enabled.append(w)
w = ttk.Checkbutton(r2, text="输出位反转", variable=fv["ref_out"])
w.pack(side="left", padx=4)
crc_widgets_enabled.append(w)
r3 = ttk.Frame(sub)
r3.grid(row=3, column=0, columnspan=4, sticky="w", pady=4)
ttk.Label(r3, text="校验起始偏移:").pack(side="left")
w = ttk.Entry(r3, textvariable=fv["c_start"], width=6)
w.pack(side="left", padx=4)
crc_widgets_enabled.append(w)
ttk.Label(r3, text="校验数据长度:").pack(side="left")
w = ttk.Entry(r3, textvariable=fv["c_len"], width=6)
w.pack(side="left", padx=4)
crc_widgets_enabled.append(w)
r4 = ttk.Frame(sub)
r4.grid(row=4, column=0, columnspan=4, sticky="w", pady=4)
ttk.Label(r4, text="CRC输出字节序:").pack(side="left")
ttk.Combobox(r4, textvariable=fv["crc_out_endian"], values=["little", "big"], width=10).pack(side="left", padx=4)
apply_preset()
def confirm():
try:
name = v_name.get().strip()
if not name:
raise ValueError("字段名称不能为空")
blen = int(v_len.get())
if blen <= 0:
raise ValueError("字节长度必须大于0")
if cls == "const":
p = {"const_hex": fv["const_hex"].get()}
elif cls == "axis":
p = {"bind_ax": fv["bind_ax"].get(), "encode": fv["encode"].get()}
if "custom" in p["encode"]:
p["expr"] = fv["expr"].get()
else:
p = {"std_mod": fv["std_mod"].get(), "poly": fv["poly"].get(),
"init": fv["init"].get(), "xor_out": fv["xor_out"].get(),
"ref_in": fv["ref_in"].get(), "ref_out": fv["ref_out"].get(),
"c_start": fv["c_start"].get(), "c_len": fv["c_len"].get(),
"crc_out_endian": fv["crc_out_endian"].get()}
new_fd = self.make_field_info(cls, name, blen, p)
self.protocol_fields[idx] = new_fd
self.refresh_protocol_tree()
self.refresh_preview()
win.destroy()
except ValueError as e:
messagebox.showerror("输入错误", str(e), parent=win)
except Exception as e:
messagebox.showerror("编辑失败", str(e), parent=win)
btn_row = ttk.Frame(container)
btn_row.grid(row=row + 1, column=0, columnspan=2, pady=10)
ttk.Button(btn_row, text="确定", command=confirm).pack(side="left", padx=8)
ttk.Button(btn_row, text="取消", command=win.destroy).pack(side="left", padx=8)
win.bind("<Return>", lambda e: confirm())
win.bind("<Escape>", lambda e: win.destroy())
def calc_crc_data(self, full_buf, fd):
try:
start = fd.get("c_start", 0)
length = fd.get("c_len", 0)
poly_str = fd.get("poly", "0x0000")
init_str = fd.get("init", "0x0000")
xorout_str = fd.get("xor_out", "0x0000")
refin = fd.get("ref_in", False)
refout = fd.get("ref_out", False)
blen = fd.get("byte_len", 2)
buf_len = len(full_buf)
real_start = max(0, start)
real_end = min(real_start + length, buf_len)
slice_data = full_buf[real_start:real_end]
self.write_log(f"[CRCDEBUG] start={start},len={length},slice_hex={slice_data.hex()},slice_len={len(slice_data)}")
if len(slice_data) == 0:
self.write_log("[CRC警告]参与CRC的数据切片为空!检查CRC起始偏移/校验长度!")
return 0
poly_raw = int(poly_str, 16)
init = int(init_str, 16)
xorout = int(xorout_str, 16)
crc_bit_map = {1: 8, 2: 16, 4: 32}
crc_bits = crc_bit_map.get(blen)
if crc_bits is None:
self.write_log(f"[CRC错误] 不支持的CRC字节长度: {blen},仅支持1/2/4字节")
return 0
crc_val = calc_crc(slice_data, crc_bits, poly_raw, init, xorout, refin, refout)
hex_digits = max(2, crc_bits // 4)
self.write_log(f"[CRC-VALUE-DBG] crcValue=0x{crc_val:0{hex_digits}X}, refIn={refin}, refOut={refout}")
return crc_val
except KeyError as ke:
self.write_log(f"[CRC异常]缺失字典key: {ke}")
return 0
except ValueError as ve:
self.write_log(f"[CRC异常]十六进制解析失败:{ve}")
return 0
except Exception as e:
self.write_log(f"CRC计算异常:{e}")
return 0
def build_protocol_packet(self, axis_value_dict):
endian_char = "<" if self.endian.get() == "little" else ">"
buf = bytearray()
crc_fields_info = []
for fd in self.protocol_fields:
blen = fd["byte_len"]
if fd["class"] == "const":
try:
hex_bytes = bytes.fromhex(fd["const_hex"])
except Exception:
hex_bytes = b""
buf.extend(hex_bytes.ljust(blen, b"\x00")[:blen])
elif fd["class"] == "axis":
ax_idx = fd["bind_ax"]
ax_val = axis_value_dict.get(ax_idx, 0.0)
enc = fd["encode"]
if "float32" in enc:
pack_data = struct.pack(endian_char + "f", ax_val)
elif "int16" in enc:
pack_data = struct.pack(endian_char + "h", self.float_to_mapped_int(ax_val, enc))
elif "Q15" in enc:
pack_data = struct.pack(endian_char + "h", self.float_to_q(ax_val, 15))
elif "Q16" in enc:
pack_data = struct.pack(endian_char + "i", self.float_to_q(ax_val, 16))
elif "Q30" in enc:
pack_data = struct.pack(endian_char + "i", self.float_to_q(ax_val, 30))
elif "Q31" in enc:
pack_data = struct.pack(endian_char + "i", self.float_to_q(ax_val, 31))
elif "custom" in enc:
pack_data = self.pack_custom_expr(ax_val, fd, endian_char)
else:
pack_data = struct.pack(endian_char + "i", self.float_to_mapped_int(ax_val, enc))
buf.extend(pack_data.ljust(blen, b"\x00")[:blen])
elif fd["class"] == "crc":
crc_fields_info.append((len(buf), fd))
buf.extend(b"\x00" * blen)
for off, fd in crc_fields_info:
crc_val = self.calc_crc_data(buf, fd)
blen = fd["byte_len"]
raw_bytes = b""
# 使用CRC字段自身存储的输出字节序,不再复用全局endian_char
crc_e = fd["crc_out_endian"]
crc_fmt_char = "<" if crc_e == "little" else ">"
if blen == 1:
raw_bytes = struct.pack("B", crc_val & 0xFF)
elif blen == 2:
raw_bytes = struct.pack(crc_fmt_char + "H", crc_val & 0xFFFF)
elif blen == 4:
raw_bytes = struct.pack(crc_fmt_char + "I", crc_val & 0xFFFFFFFF)
raw_bytes = raw_bytes.ljust(blen, b"\x00")[:blen]
self.write_log(f"[CRCDEBUG]写入buf偏移{off}, raw_bytes hex={raw_bytes.hex()}")
buf[off:off + blen] = raw_bytes
return bytes(buf)
def refresh_preview(self):
# 同步刷新协议页与手柄页两处数据包预览
if len(self.protocol_fields) == 0:
msg = "暂无协议字段,请添加字段后预览"
else:
axis_real_data = {}
info_text = "当前所有轴实时值:"
for idx, val in enumerate(self.axis_val):
ax_num = idx + 1
axis_real_data[ax_num] = val
info_text += f"轴{ax_num}={val:.17g} "
pkt = self.build_protocol_packet(axis_real_data)
hex_str = pkt.hex(" ").upper()
msg = f"{info_text}\n数据包十六进制:{hex_str}"
for box in (self.proto_preview, self.handle_preview):
if box:
box.delete(1.0, tk.END)
box.insert(tk.END, msg)
# ========== 收发控制台页面 ==========
def build_comm_tab(self, parent):
# 通道类型选择
sel_frame = ttk.LabelFrame(parent, text="通信通道选择")
sel_frame.pack(fill="x", padx=6, pady=4)
ttk.Label(sel_frame, text="通道类型:").grid(row=0, column=0, padx=4, pady=4)
ttk.Radiobutton(sel_frame, text="串口(TTL/RS232)", variable=self.comm_type, value="TTL_RS232", command=self.on_comm_type_change).grid(row=0, column=1, padx=2)
ttk.Radiobutton(sel_frame, text="USB-CDC", variable=self.comm_type, value="CDC", command=self.on_comm_type_change).grid(row=0, column=2, padx=2)
ttk.Radiobutton(sel_frame, text="CAN总线", variable=self.comm_type, value="CAN", command=self.on_comm_type_change).grid(row=0, column=3, padx=2)
ttk.Radiobutton(sel_frame, text="USB-HID", variable=self.comm_type, value="USB_HID", command=self.on_comm_type_change).grid(row=0, column=4, padx=2)
# 通道参数配置(随通道类型动态渲染)
self.comm_param_frame = ttk.LabelFrame(parent, text="通道参数配置")
self.comm_param_frame.pack(fill="x", padx=6, pady=4)
# 打开/关闭通道按钮
btn_frame = ttk.Frame(parent)
btn_frame.pack(pady=6)
self.btn_toggle = ttk.Button(btn_frame, text="打开通道", command=self.toggle_comm)
self.btn_toggle.pack(padx=10)
# 报文发送控制
tx_frame = ttk.LabelFrame(parent, text="报文发送控制")
tx_frame.pack(padx=6, pady=4, fill="x")
ttk.Button(tx_frame, text="单次发送当前协议帧", command=self.do_single_send).grid(row=0, column=0, padx=6, pady=4)
ttk.Checkbutton(tx_frame, text="周期发送", variable=self.var_cyclic_tx, command=self.toggle_cyclic_send).grid(row=0, column=1, padx=6)
ttk.Label(tx_frame, text="周期(ms):").grid(row=0, column=2)
ttk.Entry(tx_frame, textvariable=self.var_tx_period_ms, width=8).grid(row=0, column=3, padx=4)
self.render_comm_params()
def render_comm_params(self):
for w in self.comm_param_frame.winfo_children():
w.destroy()
ct = self.comm_type.get()
if ct in ("TTL_RS232", "CDC"):
self.render_serial_params()
elif ct == "CAN":
self.render_can_params()
elif ct == "USB_HID":
self.render_hid_params()
self.update_toggle_btn_text()
def render_serial_params(self):
f = self.comm_param_frame
ct = self.comm_type.get()
row = 0
ttk.Label(f, text="COM端口:").grid(row=row, column=0, padx=4, pady=4)
self.cb_port = ttk.Combobox(f, textvariable=self.com_port, width=14)
self.cb_port.grid(row=row, column=1)
ttk.Button(f, text="刷新端口列表", command=self.refresh_com_port).grid(row=row, column=2, padx=4)
param_widgets = []
cfg_items = [("波特率:", self.baud, ["9600", "115200", "230400", "460800"], 3, 4),
("数据位:", self.databit, ["8", "7"], 5, 6),
("停止位:", self.stopbit, ["1", "2"], 7, 8),
("校验位:", self.parity, ["N无", "O奇", "E偶"], 9, 10)]
for text, var, vals, c0, c1 in cfg_items:
ttk.Label(f, text=text).grid(row=row, column=c0, padx=4)
w = ttk.Combobox(f, textvariable=var, values=vals, width=6)
w.grid(row=row, column=c1)
param_widgets.append(w)
if ct == "CDC":
# USB-CDC:传输速率由USB协议栈决定,通信参数配置无效,直接禁用
for w in param_widgets:
w.config(state="disabled")
ttk.Label(f, text="USB-CDC无需配置波特率等参数(由USB协议栈决定),仅选择端口即可", foreground="#882200").grid(row=row + 1, column=0, columnspan=12, sticky="w", padx=4, pady=2)
self.refresh_com_port()
def render_can_params(self):
f = self.comm_param_frame
row = 0
ttk.Label(f, text="CAN模式:").grid(row=row, column=0, padx=4, pady=4)
cb_mode = ttk.Combobox(f, textvariable=self.can_mode, values=["CAN2.0", "CAN-FD"], state="readonly", width=8)
cb_mode.grid(row=row, column=1)
cb_mode.bind("<<ComboboxSelected>>", self.on_can_mode_change)
ttk.Label(f, text="DLC:").grid(row=row, column=2, padx=8)
self.cb_dlc = ttk.Combobox(f, textvariable=self.can_dlc, width=4, values=[str(x) for x in range(0, 9)], state="readonly")
self.cb_dlc.grid(row=row, column=3)
row += 1
ttk.Label(f, text="适配器接口:").grid(row=row, column=0, padx=4, pady=4)
ttk.Combobox(f, textvariable=self.can_interface, values=CAN_INTERFACES, width=12, state="readonly").grid(row=row, column=1)
ttk.Label(f, text="通道号:").grid(row=row, column=2, padx=8)
ttk.Entry(f, textvariable=self.can_chan, width=8).grid(row=row, column=3)
ttk.Label(f, text="发送ID(hex):").grid(row=row, column=4, padx=8)
ttk.Entry(f, textvariable=self.can_txid, width=8).grid(row=row, column=5)
row += 1
ttk.Label(f, text="仲裁波特率:").grid(row=row, column=0, padx=4, pady=4)
ttk.Combobox(f, textvariable=self.can_arb_baud, values=[str(x) for x in CAN_ARB_BAUD], width=10, state="readonly").grid(row=row, column=1)
ttk.Label(f, text="FD数据波特率:").grid(row=row, column=2, padx=8)
self.cb_fd_baud = ttk.Combobox(f, textvariable=self.can_fd_baud, values=[str(x) for x in CAN_FD_BAUD], width=10, state="readonly")
self.cb_fd_baud.grid(row=row, column=3)
ttk.Checkbutton(f, text="扩展帧ID", variable=self.can_ext).grid(row=row, column=4, padx=8)
if not CAN_AVAIL:
for w in f.winfo_children():
w["state"] = "disabled"
ttk.Label(f, text="未安装 python-can 库,CAN通道不可用", foreground="#882200").grid(row=row + 1, column=0, columnspan=8, sticky="w", padx=4, pady=2)
self.on_can_mode_change()
def render_hid_params(self):
f = self.comm_param_frame
row = 0
ttk.Label(f, text="VID(十六进制):").grid(row=row, column=0, padx=4, pady=4)
ttk.Entry(f, textvariable=self.usb_vid, width=10).grid(row=row, column=1)
ttk.Label(f, text="PID(十六进制):").grid(row=row, column=2, padx=8)
ttk.Entry(f, textvariable=self.usb_pid, width=10).grid(row=row, column=3)
ttk.Label(f, text="报告长度(字节):").grid(row=row, column=4, padx=8)
ttk.Entry(f, textvariable=self.usb_report_len, width=6).grid(row=row, column=5)
if not HID_AVAIL:
for w in f.winfo_children():
w["state"] = "disabled"
ttk.Label(f, text="未安装 hidapi 库,USB-HID通道不可用", foreground="#882200").grid(row=row + 1, column=0, columnspan=8, sticky="w", padx=4, pady=2)
def on_can_mode_change(self, event=None):
fd_on = self.can_mode.get() == "CAN-FD"
if getattr(self, "cb_fd_baud", None):
self.cb_fd_baud.config(state="readonly" if fd_on else "disabled")
if getattr(self, "cb_dlc", None):
if fd_on:
self.cb_dlc["values"] = [str(x) for x in (0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 20, 24, 32, 48, 64)]
else:
self.cb_dlc["values"] = [str(x) for x in range(0, 9)]
if int(self.can_dlc.get()) > 8:
self.can_dlc.set("8")
def on_comm_type_change(self, event=None):
# 切换通道前关闭当前所有设备与周期发送
self.close_all_devices()
self._comm_enabled = False
self.render_comm_params()
self.save_comm_cfg()
def update_toggle_btn_text(self):
names = {"TTL_RS232": "串口", "CDC": "USB-CDC", "CAN": "CAN通道", "USB_HID": "USB-HID"}
name = names.get(self.comm_type.get(), "通道")
opened = self.is_current_open()
self.btn_toggle.config(text=f"关闭{name}" if opened else f"打开{name}")
def is_current_open(self):
ct = self.comm_type.get()
if ct in ("TTL_RS232", "CDC"):
return bool(self.ser and self.ser.is_open)
if ct == "CAN":
return self.can_bus is not None
if ct == "USB_HID":
return self.usb_dev is not None
return False
def toggle_comm(self):
ct = self.comm_type.get()
if ct in ("TTL_RS232", "CDC"):
if self.ser and self.ser.is_open:
self.close_serial()
else:
self.open_serial()
elif ct == "CAN":
if self.can_bus is not None:
self.close_can()
else:
self.open_can()
elif ct == "USB_HID":
if self.usb_dev is not None:
self.close_hid()
else:
self.open_hid()
def close_all_devices(self):
"""关闭所有通道设备、停止周期发送/指令回放与接收线程(不修改日志开关)"""
self._cyclic_run = False
if self._cyclic_after_id is not None:
try:
self.root.after_cancel(self._cyclic_after_id)
except Exception:
pass
self._cyclic_after_id = None
self.var_cyclic_tx.set(False)
self.stop_csv_playback()
self.var_csv_run.set(False)
self.stop_recv()
if self.ser and self.ser.is_open:
try:
self.ser.close()
except Exception:
pass
self.ser = None
if self.can_bus is not None:
try:
self.can_bus.shutdown()
except Exception:
pass
self.can_bus = None
if self.usb_dev is not None:
try:
self.usb_dev.close()
except Exception:
pass
self.usb_dev = None
# ---- 接收线程(按当前通道启动对应循环) ----
def start_recv(self):
self.stop_recv()
self.recv_run = True
# 主线程快照通道类型与HID报文长度,避免子线程读取tk变量
self.recv_thread = threading.Thread(target=self._recv_loop, args=(self.comm_type.get(), int(self.usb_report_len.get())), daemon=True)
self.recv_thread.start()
def stop_recv(self):
self.recv_run = False
if self.recv_thread and self.recv_thread.is_alive():
self.recv_thread.join(timeout=0.3)
self.recv_thread = None
def _recv_loop(self, ct, hid_report_len):
if ct in ("TTL_RS232", "CDC"):
self._serial_recv_loop()
elif ct == "CAN":
self._can_recv_loop()
elif ct == "USB_HID":
self._hid_recv_loop(hid_report_len)
def _serial_recv_loop(self):
buffer = b""
last_byte_time = time.time()
while self.recv_run and self.ser and self.ser.is_open:
try:
cnt = self.ser.in_waiting
if cnt > 0:
data = self.ser.read(cnt)
buffer += data
last_byte_time = time.time()
if len(buffer) > 4096:
buffer = buffer[-2048:]
else:
# 空闲10ms判定为完整一帧
if buffer and time.time() - last_byte_time >= 0.01:
self.write_log(f"【串口接收】{buffer.hex(' ').upper()}")
buffer = b""
time.sleep(0.001)
except Exception as e:
self.write_log(f"【串口接收异常】{e}")
buffer = b""
time.sleep(0.01)
def _can_recv_loop(self):
while self.recv_run and self.can_bus is not None:
try:
msg = self.can_bus.recv(timeout=0.01)
if msg is not None:
ext = "扩展帧" if msg.is_extended_id else "标准帧"
fd = "[FD]" if msg.is_fd else ""
self.write_log(f"【CAN接收】ID=0x{msg.arbitration_id:X} {ext}{fd} DATA:{msg.data.hex(' ').upper()}")
except Exception:
time.sleep(0.01)
def _hid_recv_loop(self, report_len):
while self.recv_run and self.usb_dev is not None:
try:
data = self.usb_dev.read(report_len, timeout_ms=50)
if data:
self.write_log(f"【HID接收】{bytes(data).hex(' ').upper()}")
except Exception:
time.sleep(0.05)
# ---- 各通道打开/关闭 ----
def open_serial(self):
if self.ser and self.ser.is_open:
return
try:
port = self.com_port.get()
ct = self.comm_type.get()
if ct == "CDC":
# USB-CDC:波特率等参数无效,固定8N1打开
baud, bits, stop, par = 115200, 8, 1, serial.PARITY_NONE
else:
baud = int(self.baud.get())
bits = int(self.databit.get())
stop = float(self.stopbit.get())
p_text = self.parity.get()
parity_map = {"N无": serial.PARITY_NONE, "O奇": serial.PARITY_ODD, "E偶": serial.PARITY_EVEN}
par = parity_map[p_text]
self.ser = serial.Serial(port=port, baudrate=baud, bytesize=bits, stopbits=stop, parity=par, timeout=0.01)
self._comm_enabled = True
self.write_log(f"【通信】{ct} 端口{port} 打开成功")
self.start_recv()
self.update_toggle_btn_text()
self.save_comm_cfg()
except Exception as e:
self.ser = None
messagebox.showerror("通道打开失败", str(e))
def close_serial(self):
self.close_all_devices()
self.write_log("【通信】串口已关闭")
self._comm_enabled = False
self.update_toggle_btn_text()
def open_can(self):
if self.can_bus is not None:
return
if not CAN_AVAIL:
messagebox.showerror("依赖缺失", "未安装 python-can 库,无法使用CAN通道")
return
try:
interface = self.can_interface.get()
chan = self.can_chan.get()
arb = int(self.can_arb_baud.get())
is_fd = self.can_mode.get() == "CAN-FD"
if is_fd:
fd_baud = int(self.can_fd_baud.get())
self.can_bus = can.Bus(interface=interface, channel=chan, fd=True, bitrate=arb, data_bitrate=fd_baud)
self.write_log(f"【通信】CAN打开成功 接口={interface} 通道={chan} 仲裁波特率={arb} FD数据波特率={fd_baud}")
else:
self.can_bus = can.Bus(interface=interface, channel=chan, bitrate=arb)
self.write_log(f"【通信】CAN打开成功 接口={interface} 通道={chan} 波特率={arb}")
self._comm_enabled = True
self.start_recv()
self.update_toggle_btn_text()
self.save_comm_cfg()
except Exception as e:
self.can_bus = None
messagebox.showerror("CAN打开失败", str(e))
def close_can(self):
self.close_all_devices()
self.write_log("【通信】CAN通道已关闭")
self._comm_enabled = False
self.update_toggle_btn_text()
def open_hid(self):
if self.usb_dev is not None:
return
if not HID_AVAIL:
messagebox.showerror("依赖缺失", "未安装 hidapi 库,无法使用USB-HID通道")
return
try:
vid = int(self.usb_vid.get(), 16)
pid = int(self.usb_pid.get(), 16)
self.usb_dev = hid.device()
self.usb_dev.open(vid, pid)
self._comm_enabled = True
self.write_log(f"【通信】USB-HID打开成功 VID:{vid:04X} PID:{pid:04X}")
self.start_recv()
self.update_toggle_btn_text()
self.save_comm_cfg()
except Exception as e:
self.usb_dev = None
messagebox.showerror("USB-HID打开失败", str(e))
def close_hid(self):
self.close_all_devices()
self.write_log("【通信】USB-HID已断开")
self._comm_enabled = False
self.update_toggle_btn_text()
def refresh_com_port(self):
self.port_list = [p.device for p in serial.tools.list_ports.comports()]
if hasattr(self, "cb_port") and self.cb_port:
self.cb_port["values"] = self.port_list
cur = self.com_port.get()
if cur not in self.port_list:
self.com_port.set(self.port_list[0] if self.port_list else "")
# ---- 通信参数持久化 ----
def load_comm_cfg(self):
"""启动时加载上次保存的通道类型与各通道参数"""
self.comm_cfg = {}
if os.path.exists(COMM_CFG_FILE):
try:
with open(COMM_CFG_FILE, "r", encoding="utf-8") as f:
self.comm_cfg = json.load(f)
except Exception:
self.comm_cfg = {}
if self.comm_cfg.get("comm_type") in ("TTL_RS232", "CDC", "CAN", "USB_HID"):
self.comm_type.set(self.comm_cfg["comm_type"])
sc = self.comm_cfg.get("serial", {})
if sc.get("port"):
self.com_port.set(sc["port"])
for var, key in ((self.baud, "baud"), (self.databit, "data"), (self.stopbit, "stop"), (self.parity, "parity")):
if sc.get(key) is not None:
var.set(str(sc[key]))
cc = self.comm_cfg.get("can", {})
if cc.get("interface"):
self.can_interface.set(cc["interface"])
if cc.get("chan") is not None:
self.can_chan.set(str(cc["chan"]))
if cc.get("arb_baud") is not None:
self.can_arb_baud.set(str(cc["arb_baud"]))
if cc.get("fd_baud") is not None:
self.can_fd_baud.set(str(cc["fd_baud"]))
if cc.get("mode"):
self.can_mode.set(cc["mode"])
if cc.get("dlc") is not None:
self.can_dlc.set(str(cc["dlc"]))
if cc.get("txid") is not None:
txid = cc["txid"]
self.can_txid.set(f"{txid:X}" if isinstance(txid, int) else str(txid))
if "ext" in cc:
self.can_ext.set(bool(cc["ext"]))
uc = self.comm_cfg.get("usb", {})
if uc.get("vid") is not None:
self.usb_vid.set(str(uc["vid"]))
if uc.get("pid") is not None:
self.usb_pid.set(str(uc["pid"]))
if uc.get("report_len") is not None:
self.usb_report_len.set(int(uc["report_len"]))
le = self.comm_cfg.get("log_enabled")
if isinstance(le, bool):
self.var_log_enabled.set(le)
self._log_enabled = le
else:
self.var_log_enabled.set(True)
self._log_enabled = True
# 手柄全局参数恢复(非法值忽略,保持默认)
js = self.comm_cfg.get("joystick", {})
try:
n = int(js["axis_count"])
if 1 <= n <= 8:
self.axis_count.set(n)
except Exception:
pass
if js.get("mode") in ("abs", "rel"):
self.mode.set(js["mode"])
for key, var in (("sensitivity", self.sensitivity), ("noise_amp", self.noise_amp), ("abs_min", self.abs_min), ("abs_max", self.abs_max)):
try:
var.set(float(js[key]))
except Exception:
pass
# 保存独立配置,等待 refresh_axis_ui 创建控件后应用
pa = js.get("per_axis")
self._loaded_per_axis = pa if isinstance(pa, list) else None
def save_comm_cfg(self):
"""保存当前通道类型与参数到json,下次启动自动恢复"""
self.comm_cfg = {
"comm_type": self.comm_type.get(),
"log_enabled": self._log_enabled,
"joystick": {
"axis_count": int(self.axis_count.get()),
"mode": self.mode.get(),
"sensitivity": self.sensitivity.get(),
"noise_amp": self.noise_amp.get(),
"abs_min": self.abs_min.get(),
"abs_max": self.abs_max.get(),
"per_axis": [
{
"sensitivity": self.axis_sensitivity[i].get(),
"noise_amp": self.axis_noise_amp[i].get(),
"abs_min": self.axis_abs_min[i].get(),
"abs_max": self.axis_abs_max[i].get(),
}
for i in range(len(self.axis_sensitivity))
] if self.axis_sensitivity else None,
},
"serial": {
"port": self.com_port.get(),
"baud": self.baud.get(),
"data": self.databit.get(),
"stop": self.stopbit.get(),
"parity": self.parity.get(),
},
"can": {
"mode": self.can_mode.get(),
"dlc": self.can_dlc.get(),
"interface": self.can_interface.get(),
"chan": self.can_chan.get(),
"txid": self.can_txid.get(),
"arb_baud": self.can_arb_baud.get(),
"fd_baud": self.can_fd_baud.get(),
"ext": bool(self.can_ext.get()),
},
"usb": {
"vid": self.usb_vid.get(),
"pid": self.usb_pid.get(),
"report_len": str(self.usb_report_len.get()),
},
}
try:
with open(COMM_CFG_FILE, "w", encoding="utf-8") as f:
json.dump(self.comm_cfg, f, indent=2, ensure_ascii=False)
except Exception:
pass
# ---- 统一发送入口 ----
def transmit_raw(self, raw: bytes, ct=None) -> bool:
"""按当前通道发送原始字节流;通道未打开返回False,发送异常记日志并返回False。
ct: 通道类型快照(周期发送线程传入,避免子线程读取tk变量)"""
if ct is None:
ct = self.comm_type.get()
try:
if ct in ("TTL_RS232", "CDC"):
if not (self.ser and self.ser.is_open):
return False
self.ser.write(raw)
self.ser.flush()
elif ct == "CAN":
if self.can_bus is None:
return False
tx_id = int(self.can_txid.get(), 16)
dlc = int(self.can_dlc.get())
data = bytearray(raw[:dlc])
data.extend(b"\x00" * (dlc - len(data)))
msg = can.Message(arbitration_id=tx_id, data=bytes(data), dlc=dlc, is_extended_id=self.can_ext.get(), is_fd=(self.can_mode.get() == "CAN-FD"))
self.can_bus.send(msg)
elif ct == "USB_HID":
if self.usb_dev is None:
return False
rlen = int(self.usb_report_len.get())
buf = bytearray(rlen)
buf[0] = 0 # Report ID固定为0,后续字节承载数据
n = min(len(raw), rlen - 1)
buf[1:1 + n] = raw[:n]
self.usb_dev.write(bytes(buf))
else:
return False
return True
except Exception as e:
self.write_log(f"【发送异常】{e}")
return False
# ========== 发送控制新增函数 ==========
def build_current_packet_all_zero(self, mode=None):
"""周期/单次发送:全部轴组包。绝对模式发当前位置;相对模式发累积增量(步幅与发送周期绑定)"""
if mode is None:
mode = self.mode.get()
axis_dict = {}
for idx in range(len(self.axis_val)):
if mode == "abs":
axis_dict[idx + 1] = self.axis_val[idx]
else:
axis_dict[idx + 1] = self.rel_pending.get(idx + 1, 0.0)
return self.build_protocol_packet(axis_dict)
def clear_rel_pending(self):
# 发送周期消费增量后清零缓冲(仅相对位移模式使用)
self.rel_pending = {k: 0.0 for k in self.rel_pending}
def do_single_send(self):
if not self._comm_enabled or not self.is_current_open():
messagebox.showwarning("提示", "请先打开通信通道")
return
pkt = self.build_current_packet_all_zero()
if not pkt:
messagebox.showerror("错误", "组包失败")
return
if self.transmit_raw(pkt):
self.write_log(f"【单次发送】hex:{pkt.hex(' ').upper()}, len={len(pkt)}")
if self.mode.get() == "rel":
self.clear_rel_pending()
else:
messagebox.showerror("发送失败", "通道发送失败,请检查通道状态")
def toggle_cyclic_send(self):
want_run = self.var_cyclic_tx.get()
if want_run:
if not self._comm_enabled or not self.is_current_open():
messagebox.showwarning("提示", "请先打开通信通道")
self.var_cyclic_tx.set(False)
return
try:
ms = int(self.var_tx_period_ms.get())
if ms < 1:
raise ValueError
except ValueError:
messagebox.showerror("参数错误", "周期必须大于0的整数ms")
self.var_cyclic_tx.set(False)
return
mode = self.mode.get()
if mode == "rel":
self.clear_rel_pending()
self._cyclic_params = (ms, self.comm_type.get(), mode)
self._cyclic_run = True
self._cyclic_after_id = self.root.after(ms, self._cyclic_tick)
else:
self._cyclic_run = False
if self._cyclic_after_id is not None:
try:
self.root.after_cancel(self._cyclic_after_id)
except Exception:
pass
self._cyclic_after_id = None
def _cyclic_tick(self):
self._cyclic_after_id = None
if not self._cyclic_run:
self.var_cyclic_tx.set(False)
return
ms, comm_type, mode = self._cyclic_params
# 通道关闭或切换后设备被清空,退出周期发送
if not self._comm_enabled:
self._cyclic_run = False
self.var_cyclic_tx.set(False)
return
if comm_type in ("TTL_RS232", "CDC"):
if not (self.ser and self.ser.is_open):
self._cyclic_run = False
self.var_cyclic_tx.set(False)
return
elif comm_type == "CAN":
if self.can_bus is None:
self._cyclic_run = False
self.var_cyclic_tx.set(False)
return
elif comm_type == "USB_HID":
if self.usb_dev is None:
self._cyclic_run = False
self.var_cyclic_tx.set(False)
return
try:
pkt = self.build_current_packet_all_zero(mode)
if pkt and self.transmit_raw(pkt, comm_type):
self.write_log(f"【周期发送】hex:{pkt.hex(' ').upper()}, len={len(pkt)}")
if mode == "rel":
self.clear_rel_pending()
except Exception:
pass
self._cyclic_after_id = self.root.after(ms, self._cyclic_tick)
# ========== 日志页面 ==========
def build_log_tab(self, parent):
log_frame = ttk.LabelFrame(parent, text="完整收发日志")
log_frame.pack(fill="both", expand=True, padx=6, pady=4)
self.log_text = scrolledtext.ScrolledText(log_frame)
self.log_text.pack(fill="both", expand=True)
bar = ttk.Frame(parent)
bar.pack(fill="x", pady=4)
ttk.Checkbutton(bar, text="打印日志", variable=self.var_log_enabled, command=self.on_log_toggle).pack(side="left", padx=8)
ttk.Button(bar, text="清空全部日志", command=lambda: self.log_text.delete(1.0, tk.END)).pack(side="right", padx=8)
def on_log_toggle(self):
self._log_enabled = self.var_log_enabled.get()
self.save_comm_cfg()
def write_log(self, msg):
if not self._comm_enabled or not self._log_enabled:
return
self._log_queue.put(msg)
if self._log_drain_after is None:
self._log_drain_after = self.root.after(30, self._drain_log_queue)
def _drain_log_queue(self):
self._log_drain_after = None
lines = []
try:
while True:
lines.append(self._log_queue.get_nowait())
except queue.Empty:
pass
if lines:
try:
self.log_text.insert(tk.END, "\n".join(lines) + "\n")
self.log_text.see(tk.END)
except tk.TclError:
pass
if not self._log_queue.empty():
self._log_drain_after = self.root.after(30, self._drain_log_queue)
# ========== 统一发送入口============
def send_signal(self, handle_signal):
# 未打开通道:不组包、不产生任何收发日志
if not self._comm_enabled:
return
axis_dict = {i + 1: self.axis_val[i] for i in range(len(self.axis_val))}
pkt_bytes = self.build_protocol_packet(axis_dict)
hex_data = pkt_bytes.hex(" ").upper()
self.write_log(f"【组包下发 十六进制】{hex_data}")
if self.transmit_raw(pkt_bytes):
self.write_log(f"【发送成功 字节长度:{len(pkt_bytes)}】")
if __name__ == "__main__":
root = tk.Tk()
app = VirtualJoystickSim(root)
def on_close():
app.close_all_devices()
app.save_comm_cfg() # 关闭时兜底保存手柄参数与通道参数
root.destroy()
root.protocol("WM_DELETE_WINDOW", on_close)
root.mainloop()
3、可执行程序生成及注意事项
(1)在程序源码文件control_system.py所在目录下打开终端,输入命令“pyinstaller -w -F control_system.py”,即可生成可执行文件control_system.exe。
(2)不管是运行control_system.py还是control_system.exe,其所在目录下如果没有小程序需要读写的JSON文件,都会直接新建,切勿随意修改它们。
(3)如果希望增加默认的CRC校验模型,不想每次新增字段都单独配置,可基于上面的源码继续开发CRC校验模型保存功能,或者在CRC_PRESET中新增默认模型。
(4)当前小程序支持的字段最小长度单位为字节,不支持按位配置,已适用于大部分应用层协议,如果需要按位配置,可基于上面的源码继续开发,将最小单位调整为bit。
359



被折叠的 条评论
为什么被折叠?



