基于Python开发自定义协议编辑&发送工具

一、小程序功能设计

1、自定义协议帧格式设计

(1)功能背景:

①在软件开发岗位的工作中,经常会涉及企业自定义的私有协议,这些私有协议不像CAN通信、USB通信、串口通信,它们往往定义在应用层,没有专门的软件对数据流进行整合或解析。

②为了减少自定义协议帧格式带来的额外工作量,可以设计程序实现协议模板定义功能,直接在小程序中编辑协议帧的各字段定义并保存为模板,用户可以使用该模板快速生成或解析自定义协议的数据帧。

(2)协议模板管理功能定义:

①打开小程序后有三个页面选项卡,其中第一个选项卡“协议模板编辑器”就是该功能的界面。

②该功能的定义与自定义协议编辑工具一致,具体可参考《基于Python开发自定义协议编辑工具》,此处不过多赘述。

2、常用报文库配置

(1)功能背景:

①自定义协议编辑工具只支持应用层协议报文的生成和解析,而在嵌入式调试中,经常需要使用上位机发送报文,如果每次发送前都在自定义协议编辑工具内部生成数据流,然后复制到其它通用的收发工具中发送,会非常繁琐。

②为了减少这种重复的工作量,可以基于自定义的协议帧格式,直接配置常用报文保存在本地,并按照使用的模板和报文用途对它们进行分类和命名,这样,后续需要使用该报文测试时,不需要重新在编辑工具中生成,而是直接在常用报文库中根据报文名找到保存的报文即可。

(2)功能定义:

①打开小程序后有三个页面选项卡,其中第二个选项卡“常用报文库”就是该功能的界面。

②报文分类管理:

[1]为避免报文库出现空分类的情况,小程序默认存在一个“默认分类”,如果用户不自行新增分类,直接配置报文,则报文默认归属于“默认分类”。

[2]点击“新增分类”,然后在弹出的输入框中输入分类名称,再点击“OK”,即可新增分类。

[3]下拉框选择想要删除的分类,然后点击“删除分类”,然后在弹出的对话框中点击“是”,即可删除分类,同时该分类下的所有报文也会一并被删除。

③根据模板配置常用报文:

[1]先在下拉框选择协议模板(如果没有对应的协议模板,则回到“协议模板编辑器”选项卡先新建、编辑、保存模板,再回到本选项卡选择协议模板)。

[2]选好报文分类以后,点击“配置报文并保存入库”,填写报文字段信息、报文名称和报文用途,然后点击“生成并保存报文”,即可在报文库中添加一条常用报文。

④编辑和删除已配置报文:

[1]在报文列表中双击报文条目,在弹出的窗口中可以编辑已配置报文,编辑完毕后点击“生成并保存报文”即可,如果报文中有CRC校验字段,则CRC校验码会重新计算。

[2]在报文列表中单击选中报文条目,然后点击“删除选中”,即可删除已配置报文。

⑤在报文列表中单击选中报文条目,然后点击“复制Hex”,即可复制报文的内容(十六进制数据流)。

3、总线发送控制台

(1)功能背景:

①如果只有常用报文库功能,那还是没办法省去复制黏贴的工作量,对此继续开发小程序功能,将发送报文的功能引入,发送控制台能直接读取报文库中的报文,用户只需配置通信接口的参数、报文的发送方式,选择想要发送的应用层报文,然后控制发送即可,数据链路层的协议封装由小程序和通信协议硬件自动实现。

②小程序计划开发串口(TTL/RS232)、CAN和USB三个比较常用的通信接口(考虑到RS485通常和Modbus配合使用,Modbus有专门的上位机,所以暂不开发RS485通信接口),在实际开发中如果有其它通信接口需求,可在该小程序的基础上继续增加功能。

③小程序在各通信接口数据链路层的处理范围:

[1]串口(TTL/RS232):

[2]CAN:

[3]USB(CDC/HID):

(2)通信接口的选择和配置:

①串口(TTL/RS232):

[1]通信通道选择“串口(TTL/RS232)”,即可选择通信接口为串口(不支持RS485)。

[2]在通信参数配置区可选择串行COM端口(小程序会自动识别可打开的COM端口,如果无法识别,点击“刷新端口”即可),然后配置通信参数,双方的通信参数必须保持一致。

[3]配置通信参数后,点击“打开/关闭串口”,即可打开串口通道,再次点击“打开/关闭串口”,即可关闭串口通道,可根据日志窗口输出的日志判断是否操作成功。

②CAN:

[1]通信通道选择“CAN”,即可选择通信接口为CAN。

[2]在通信参数配置区可配置通信参数,包括CAN/CAN FD、DLC、发送报文ID、波特率、是否为扩展帧等。

[3]配置通信参数后,点击“打开/关闭CAN通道”,即可打开CAN通道,再次点击“打开/关闭CAN通道”,即可关闭CAN通道,可根据日志窗口输出的日志判断是否操作成功。

③USB-CDC:

[1]通信通道选择“USB-CDC”,即可选择通信接口为USB-CDC。

[2]在通信参数配置区可选择CDC-COM端口(小程序会自动识别可打开的COM端口,如果无法识别,点击“刷新端口”即可),然后配置通信参数,但需要注意的是,USB-CDC的实际波特率由USB外设决定。

[3]配置通信参数后,点击“打开/关闭USB-CDC”,即可打开USB-CDC通道,再次点击“打开/关闭USB-CDC”,即可关闭USB-CDC通道,可根据日志窗口输出的日志判断是否操作成功。

④USB-HID:

[1]通信通道选择“USB-HID”,即可选择通信接口为USB-HID。

[2]在通信参数配置区可配置VID和PID。

[3]配置通信参数后,点击“打开/关闭USB-HID”,即可打开USB-HID通道,再次点击“打开/关闭USB-HID”,即可关闭USB-HID通道,可根据日志窗口输出的日志判断是否操作成功。

(3)报文发送列表管理:

①添加待发送报文:选择报文分类,然后选择分类下的具体报文,再点击“添加到发送列表”,此时会弹出输入框,如果希望该报文能够周期性地自动发送,则输入报文周期(单位为ms),如果只希望该报文能够手动单次发送,则输入“0”即可。

②编辑报文发送参数:双击待发送报文列表中的报文条目,或者选中它,再点击“编辑选中”,可修改报文的发送周期;需要注意的是,这里不能编辑报文的内容,如果报文库中的报文被修改,则待发送报文列表的报文需要重新添加。

③删除待发送报文:单击待发送报文列表中的报文条目,再点击“删除选中”,即可将该报文条目从待发送报文列表中移除;如果点击“清空列表”,将会移除待发送报文列表中的全部报文条目。

(4)报文发送控制(以串口发送为例):

[1]单次发送:选中待发送报文列表中的报文条目,然后点击“发送选中报文(单次)”,即可通过串口将选中报文发出,且仅发出一次。

[2]周期发送:直接勾选“全局启动周期发送”,待发送列表中启用周期发送的报文将自动按照配置的周期发送。

(5)收发日志窗口:

[1]清空日志:点击“清空日志”,即可清除日志窗口中的全部内容。

[2]接收数据日志的打印:取消勾选“打印接收数据日志”,即可不打印小程序接收到的数据(防止总线上密集的报文将日志窗口刷屏)。

二、小程序开发

1、开发环境

(1)需要确认安装的Python是否自带tkinter库(图形依赖),有则无需单独安装。

(2)安装pyinstaller库,该库主要用于将.py打包成可执行文件.exe,使小程序运行能够脱离Python环境。

(3)安装serial库、can库、hid库,这几个库用于支持通信接口开发。

2、程序源码

(1)JSON配置文件示例:

①协议模板配置文件protocol_templates.json示例:

[
  {
    "name": "自定义协议帧",
    "fields": [
      {
        "name": "普通字段",
        "type": "普通",
        "length": 1,
        "var_mode": "fixed",
        "bind_field": "",
        "endian": "BE(大端)",
        "enum_dict": {},
        "crc_model": "CRC-8",
        "crc_start_field": "",
        "crc_range_field": "",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      },
      {
        "name": "枚举字段",
        "type": "枚举",
        "length": 1,
        "var_mode": "fixed",
        "bind_field": "",
        "endian": "BE(大端)",
        "enum_dict": {
          "枚举1": 0,
          "枚举2": 1
        },
        "crc_model": "CRC-8",
        "crc_start_field": "",
        "crc_range_field": "",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      },
      {
        "name": "校验字段",
        "type": "CRC",
        "length": 2,
        "var_mode": "fixed",
        "bind_field": "",
        "endian": "BE(大端)",
        "enum_dict": {},
        "crc_model": "自定义",
        "crc_start_field": "普通字段",
        "crc_range_field": "枚举字段",
        "crc_custom": {
          "width": 16,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      },
      {
        "name": "有效数据长度",
        "type": "普通",
        "length": 1,
        "var_mode": "fixed",
        "bind_field": "",
        "endian": "BE(大端)",
        "enum_dict": {},
        "crc_model": "CRC-8",
        "crc_start_field": "",
        "crc_range_field": "",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      },
      {
        "name": "数据字段",
        "type": "普通",
        "length": 1,
        "var_mode": "bind",
        "bind_field": "有效数据长度",
        "endian": "BE(大端)",
        "enum_dict": {},
        "crc_model": "CRC-8",
        "crc_start_field": "",
        "crc_range_field": "",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      },
      {
        "name": "帧结束标志",
        "type": "普通",
        "length": 1,
        "var_mode": "fixed",
        "bind_field": "",
        "endian": "BE(大端)",
        "enum_dict": {},
        "crc_model": "CRC-8",
        "crc_start_field": "",
        "crc_range_field": "",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      }
    ]
  },
  {
    "name": "伪CAN数据帧",
    "fields": [
      {
        "name": "ID仲裁段",
        "type": "普通",
        "length": 3,
        "var_mode": "fixed",
        "bind_field": "",
        "endian": "BE(大端)",
        "enum_dict": {},
        "crc_model": "CRC-8",
        "crc_start_field": "",
        "crc_range_field": "",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      },
      {
        "name": "控制段",
        "type": "枚举",
        "length": 1,
        "var_mode": "fixed",
        "bind_field": "",
        "endian": "BE(大端)",
        "enum_dict": {
          "读命令": 0,
          "写命令": 1,
          "读返回": 2,
          "写返回": 3
        },
        "crc_model": "CRC-8",
        "crc_start_field": "",
        "crc_range_field": "",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      },
      {
        "name": "有效数据长度",
        "type": "普通",
        "length": 1,
        "var_mode": "fixed",
        "bind_field": "",
        "endian": "BE(大端)",
        "enum_dict": {},
        "crc_model": "CRC-8",
        "crc_start_field": "",
        "crc_range_field": "",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      },
      {
        "name": "数据段",
        "type": "普通",
        "length": 1,
        "var_mode": "bind",
        "bind_field": "有效数据长度",
        "endian": "BE(大端)",
        "enum_dict": {},
        "crc_model": "CRC-8",
        "crc_start_field": "",
        "crc_range_field": "",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      },
      {
        "name": "CRC段",
        "type": "CRC",
        "length": 2,
        "var_mode": "fixed",
        "bind_field": "",
        "endian": "BE(大端)",
        "enum_dict": {},
        "crc_model": "CRC-16/MODBUS",
        "crc_start_field": "ID仲裁段",
        "crc_range_field": "数据段",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      },
      {
        "name": "帧结束",
        "type": "普通",
        "length": 1,
        "var_mode": "fixed",
        "bind_field": "",
        "endian": "BE(大端)",
        "enum_dict": {},
        "crc_model": "CRC-8",
        "crc_start_field": "",
        "crc_range_field": "",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      }
    ]
  },
  {
    "name": "令牌伪USB数据帧",
    "fields": [
      {
        "name": "PID",
        "type": "枚举",
        "length": 1,
        "var_mode": "fixed",
        "bind_field": "",
        "endian": "BE(大端)",
        "enum_dict": {
          "IN": 0,
          "OUT": 1,
          "SETUP": 2
        },
        "crc_model": "CRC-8",
        "crc_start_field": "",
        "crc_range_field": "",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      },
      {
        "name": "ADDR",
        "type": "普通",
        "length": 1,
        "var_mode": "fixed",
        "bind_field": "",
        "endian": "BE(大端)",
        "enum_dict": {},
        "crc_model": "CRC-8",
        "crc_start_field": "",
        "crc_range_field": "",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      },
      {
        "name": "EP",
        "type": "普通",
        "length": 1,
        "var_mode": "fixed",
        "bind_field": "",
        "endian": "BE(大端)",
        "enum_dict": {},
        "crc_model": "CRC-8",
        "crc_start_field": "",
        "crc_range_field": "",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      },
      {
        "name": "CRC-8",
        "type": "CRC",
        "length": 1,
        "var_mode": "fixed",
        "bind_field": "",
        "endian": "BE(大端)",
        "enum_dict": {},
        "crc_model": "CRC-8",
        "crc_start_field": "PID",
        "crc_range_field": "EP",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      }
    ]
  },
  {
    "name": "特殊伪USB数据帧",
    "fields": [
      {
        "name": "PID",
        "type": "枚举",
        "length": 1,
        "var_mode": "fixed",
        "bind_field": "",
        "endian": "BE(大端)",
        "enum_dict": {
          "SOF": 3
        },
        "crc_model": "CRC-8",
        "crc_start_field": "",
        "crc_range_field": "",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      },
      {
        "name": "FRAME",
        "type": "普通",
        "length": 2,
        "var_mode": "fixed",
        "bind_field": "",
        "endian": "BE(大端)",
        "enum_dict": {},
        "crc_model": "CRC-8",
        "crc_start_field": "",
        "crc_range_field": "",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      },
      {
        "name": "CRC-8",
        "type": "CRC",
        "length": 1,
        "var_mode": "fixed",
        "bind_field": "",
        "endian": "BE(大端)",
        "enum_dict": {},
        "crc_model": "CRC-8",
        "crc_start_field": "PID",
        "crc_range_field": "FRAME",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      }
    ]
  },
  {
    "name": "握手伪USB数据帧",
    "fields": [
      {
        "name": "PID",
        "type": "枚举",
        "length": 1,
        "var_mode": "fixed",
        "bind_field": "",
        "endian": "BE(大端)",
        "enum_dict": {
          "ACK": 4,
          "NAK": 5,
          "STALL": 6
        },
        "crc_model": "CRC-8",
        "crc_start_field": "",
        "crc_range_field": "",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      }
    ]
  },
  {
    "name": "数据伪USB数据帧",
    "fields": [
      {
        "name": "PID",
        "type": "枚举",
        "length": 1,
        "var_mode": "fixed",
        "bind_field": "",
        "endian": "BE(大端)",
        "enum_dict": {
          "DATA0": 7,
          "DATA1": 8
        },
        "crc_model": "CRC-8",
        "crc_start_field": "",
        "crc_range_field": "",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      },
      {
        "name": "Payload",
        "type": "普通",
        "length": 1,
        "var_mode": "free",
        "bind_field": "",
        "endian": "BE(大端)",
        "enum_dict": {},
        "crc_model": "CRC-8",
        "crc_start_field": "",
        "crc_range_field": "",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      },
      {
        "name": "CRC-16",
        "type": "CRC",
        "length": 2,
        "var_mode": "fixed",
        "bind_field": "",
        "endian": "BE(大端)",
        "enum_dict": {},
        "crc_model": "CRC-16/MODBUS",
        "crc_start_field": "PID",
        "crc_range_field": "Payload",
        "crc_custom": {
          "width": 8,
          "poly": 0,
          "init": 0,
          "xorout": 0,
          "refin": false,
          "refout": false
        }
      }
    ]
  }
]

②报文分类配置文件msg_category.json示例:

[
  "默认分类",
  "新增分类测试"
]

③报文库配置文件message_lib.json示例:

[
  {
    "category": "新增分类测试",
    "msg_name": "示例报文",
    "template_name": "伪CAN数据帧",
    "hex_data": "11 45 14 01 03 14 45 11 21 4F 0A",
    "remark": "演示使用",
    "field_input": {
      "ID仲裁段": "11 45 14",
      "控制段": "写命令",
      "有效数据长度": "03",
      "数据段": "14 45 11",
      "帧结束": "0A"
    }
  },
  {
    "category": "默认分类",
    "msg_name": "发送示例报文",
    "template_name": "令牌伪USB数据帧",
    "hex_data": "02 13 32 20",
    "remark": "演示使用",
    "field_input": {
      "PID": "SETUP",
      "ADDR": "13",
      "EP": "32"
    }
  }
]

④通信接口配置文件comm_config.json示例:

{
  "active_comm": "\u4e32\u53e3\uff08TTL/RS232\uff09",
  "serial": {
    "port": "COM12",
    "baud": 9600,
    "data": 8,
    "stop": 1.0,
    "parity": "N"
  },
  "can": {
    "channel": "0",
    "arb_baud": 500000,
    "fd_baud": 2000000,
    "fd_enable": false,
    "ext": false,
    "tx_id": 2047
  },
  "usb_cdc": {
    "port": "COM11",
    "baud": 115200,
    "data": 8,
    "stop": 1.0,
    "parity": "N"
  },
  "usb_vid": {
    "vid": 0,
    "pid": 0
  },
  "period_ms": 1000,
  "global_auto": false
}

(2)程序源码文件protocol_sender.py:

import json
import os
import time
import threading
import binascii
import tkinter as tk
from tkinter import ttk, messagebox, scrolledtext, simpledialog
try:
    import serial
    from serial.tools import list_ports
    SERIAL_AVAIL = True
except ImportError:
    serial = None
    list_ports = None
    SERIAL_AVAIL = False

try:
    import hid
    USB_AVAIL = True
except ImportError:
    hid = None
    USB_AVAIL = False

try:
    import can
    CAN_AVAIL = True
except ImportError:
    can = None
    CAN_AVAIL = False

# ===================== 全局文件常量 =====================
TEMPLATE_FILE = "protocol_templates.json"
MSG_LIB_FILE = "message_lib.json"
MSG_CATEGORY_FILE = "msg_category.json"
COMM_CFG_FILE = "comm_config.json"
CRC_MODEL_LIST = ["CRC-16/XMODEM", "CRC-16/MODBUS", "CRC-8", "CRC-32", "自定义"]
DEFAULT_PRESETS = {
    "CRC-16/XMODEM": {"width": 16, "poly": 0x1021, "init": 0x0000, "xorout": 0x0000, "refin": False, "refout": False},
    "CRC-16/MODBUS": {"width": 16, "poly": 0xA001, "init": 0xFFFF, "xorout": 0x0000, "refin": True, "refout": True},
    "CRC-8": {"width": 8, "poly": 0x07, "init": 0x00, "xorout": 0x00, "refin": False, "refout": False},
    "CRC-32": {"width": 32, "poly": 0x04C11DB7, "init": 0xFFFFFFFF, "xorout": 0xFFFFFFFF, "refin": True, "refout": True},
}
# 串口完整参数列表
SERIAL_BAUDS = [1200, 2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 230400, 460800, 921600]
SERIAL_STOP = [0.5, 1, 1.5, 2]
SERIAL_DATA = [5, 6, 7, 8]
SERIAL_PARITY = ["N", "O", "E"]
# CAN波特率
CAN_ARB_BAUD = [125000, 250000, 500000, 800000, 1000000]
CAN_FD_BAUD = [500000, 1000000, 2000000, 4000000, 8000000]
# CAN适配器接口
CAN_INTERFACES = ["pcan", "canalystii", "slcan", "usb2can", "vector", "socketcan"]

# ===================== CRC工具函数 =====================

def reverse_bits(value, bit_width):
    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):
    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

# ===================== Tab0 协议模板编辑器(原版完整无删减) =====================

class ProtocolEditor:
    def __init__(self, parent_frame, notify_refresh):
        self.parent = parent_frame
        self.notify_refresh = notify_refresh
        self.templates = []
        self.current_template = None
        self.field_widgets = []
        self.load_templates()
        top_frame = ttk.Frame(self.parent)
        top_frame.pack(fill="x", padx=10, pady=5)
        ttk.Label(top_frame, text="协议模板 NAME:").grid(row=0, column=0, sticky="w")
        self.cmb_template = ttk.Combobox(top_frame, width=34)
        self.cmb_template.grid(row=0, column=1, padx=5)
        ttk.Button(top_frame, text="载入", command=self.load_selected_template).grid(row=0, column=2)
        ttk.Button(top_frame, text="新建", command=self.new_template).grid(row=0, column=3, padx=2)
        ttk.Button(top_frame, text="保存模板", command=self.save_template).grid(row=0, column=4, padx=2)
        ttk.Button(top_frame, text="删除模板", command=self.del_template).grid(row=0, column=5, padx=2)
        ttk.Label(self.parent, text="字段定义列表(双击条目编辑)").pack(anchor="w", padx=10)
        self.tree = ttk.Treeview(self.parent, columns=("name", "type", "length_info", "endian", "extra"), show="headings", height=9)
        self.tree.heading("name", text="字段名")
        self.tree.heading("type", text="类型")
        self.tree.heading("length_info", text="长度信息")
        self.tree.heading("endian", text="大小端")
        self.tree.heading("extra", text="附加配置")
        self.tree.pack(fill="x", padx=10)
        self.tree.bind("<Double-1>", self.on_tree_double_click)
        btn_field = ttk.Frame(self.parent)
        btn_field.pack(anchor="w", padx=10, pady=3)
        ttk.Button(btn_field, text="添加字段", command=self.add_field).pack(side="left")
        ttk.Button(btn_field, text="删除选中", command=self.del_field).pack(side="left", padx=5)
        ttk.Label(self.parent, text="帧参数输入面板").pack(anchor="w", padx=10, pady=8)
        self.form_frame = ttk.Frame(self.parent)
        self.form_frame.pack(fill="x", padx=10)
        btn_frame = ttk.Frame(self.parent)
        btn_frame.pack(pady=8)
        ttk.Button(btn_frame, text="生成协议帧", command=self.build_frame).pack(side="left", padx=5)
        ttk.Button(btn_frame, text="解析接收报文", command=self.parse_frame).pack(side="left", padx=5)
        ttk.Button(btn_frame, text="清空输入", command=self.clear_input_values).pack(side="left", padx=5)
        out_frame = ttk.Frame(self.parent)
        out_frame.pack(fill="x", padx=10)
        out_frame.columnconfigure(1, weight=1)
        ttk.Label(out_frame, text="待解析Hex / 生成结果:").grid(row=0, column=0, sticky="w")
        self.var_hex = tk.StringVar()
        ttk.Entry(out_frame, textvariable=self.var_hex, font=("Consolas", 11)).grid(row=0, column=1, padx=6, sticky="ew")
        ttk.Button(out_frame, text="复制", command=lambda: self.parent.clipboard_append(self.var_hex.get())).grid(row=0, column=2)
        self.refresh_template_combo()

    def load_templates(self):
        if os.path.exists(TEMPLATE_FILE):
            try:
                with open(TEMPLATE_FILE, "r", encoding="utf-8") as f:
                    self.templates = json.load(f)
            except:
                self.templates = []
        else:
            self.templates = []

    def save_templates(self):
        with open(TEMPLATE_FILE, "w", encoding="utf-8") as f:
            json.dump(self.templates, f, ensure_ascii=False, indent=2)

    def refresh_template_combo(self):
        self.cmb_template["values"] = [t["name"] for t in self.templates]

    def new_template(self):
        dlg = tk.Toplevel(self.parent.winfo_toplevel())
        dlg.title("新建协议模板")
        dlg.geometry("340x130")
        dlg.transient(self.parent.winfo_toplevel())
        dlg.grab_set()
        dlg.columnconfigure(1, weight=1)
        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
            self.current_template = {"name": name, "fields": []}
            self.refresh_tree()
            self.build_form()
            dlg.destroy()
            messagebox.showinfo("完成", f"新建模板:{name}", parent=self.parent.winfo_toplevel())
            if self.notify_refresh:
                self.notify_refresh()
        ttk.Button(dlg, text="确定", command=confirm).grid(row=1, column=0, columnspan=2, pady=5)
        dlg.bind("<Return>", lambda e: confirm())

    def save_template(self):
        if not self.current_template:
            messagebox.showwarning("提示", "请先新建/加载模板")
            return
        name = self.current_template["name"]
        found = False
        for idx, t in enumerate(self.templates):
            if t["name"] == name:
                self.templates[idx] = self.current_template
                found = True
                break
        if not found:
            self.templates.append(self.current_template)
        self.save_templates()
        self.refresh_template_combo()
        if self.notify_refresh:
            self.notify_refresh()
        messagebox.showinfo("成功", "模板已保存")

    def del_template(self):
        name = self.cmb_template.get()
        self.templates = [t for t in self.templates if t["name"] != name]
        self.save_templates()
        self.refresh_template_combo()
        self.current_template = None
        self.refresh_tree()
        self.clear_form()
        if self.notify_refresh:
            self.notify_refresh()
        messagebox.showinfo("完成", "模板已删除")

    def load_selected_template(self):
        name = self.cmb_template.get()
        for t in self.templates:
            if t["name"] == name:
                self.current_template = t
                self.refresh_tree()
                self.build_form()
                return

    def refresh_tree(self):
        for item in self.tree.get_children():
            self.tree.delete(item)
        if not self.current_template:
            return
        for f in self.current_template["fields"]:
            var_mode = f.get("var_mode", "fixed")
            bind_name = f.get("bind_field", "")
            if var_mode == "fixed":
                len_info = f"{f['length']}字节"
            elif var_mode == "bind":
                len_info = f"不定长(绑定:{bind_name})"
            else:
                len_info = "不定长(自由段)"
            ftype = f["type"]
            if ftype == "CRC":
                extra = f["crc_model"]
            elif ftype == "枚举":
                extra = str(f.get("enum_dict", {}))
            else:
                extra = ""
            if f.get("var_mode") in ("bind", "free"):
                endian_show = "原始数据流"
            else:
                endian_show = f.get("endian", "BE(大端)")
            self.tree.insert("", "end", values=(f["name"], f["type"], len_info, endian_show, extra))

    def on_tree_double_click(self, event=None):
        sel = self.tree.selection()
        if not sel:
            return
        idx = self.tree.index(sel[0])
        field = self.current_template["fields"][idx]
        self.edit_field_window(field, idx)

    def add_field(self):
        empty_field = {
            "name": "", "type": "普通", "length": 1, "var_mode": "fixed", "bind_field": "",
            "endian": "BE(大端)", "enum_dict": {},
            "crc_model": "CRC-8",
            "crc_start_field": "",
            "crc_range_field": "",
            "crc_custom": {"width": 8, "poly": 0, "init": 0, "xorout": 0, "refin": False, "refout": False}
        }
        self.edit_field_window(empty_field, -1)

    def del_enum(self, tree):
        tree.focus()
        sel = tree.selection()
        if not sel:
            messagebox.showwarning("提示", "请先选中一行枚举条目!")
            return
        tree.delete(sel[0])

    def edit_field_window(self, field, index):
        win = tk.Toplevel(self.parent.winfo_toplevel())
        win.title("字段配置")
        win.geometry("720x660")
        win.transient(self.parent.winfo_toplevel())
        win.grab_set()
        win.columnconfigure(1, weight=1)
        vars = {
            "name": tk.StringVar(value=field["name"]),
            "ftype": tk.StringVar(value=field["type"]),
            "length": tk.StringVar(value=str(field["length"])),
            "var_mode": tk.StringVar(value=field.get("var_mode", "fixed")),
            "bind_field": tk.StringVar(value=field.get("bind_field", "")),
            "endian": tk.StringVar(value=field.get("endian", "BE(大端)")),
            "crc_model": tk.StringVar(value=field.get("crc_model", "CRC-8")),
            "crc_start_field": tk.StringVar(value=field.get("crc_start_field", "")),
            "crc_range_field": tk.StringVar(value=field.get("crc_range_field", "")),
            "crc_width": tk.StringVar(value=str(field.get("crc_custom", {}).get("width", 8))),
            "crc_poly": tk.StringVar(value=hex(field.get("crc_custom", {}).get("poly", 0))[2:]),
            "crc_init": tk.StringVar(value=hex(field.get("crc_custom", {}).get("init", 0))[2:]),
            "crc_xorout": tk.StringVar(value=hex(field.get("crc_custom", {}).get("xorout", 0))[2:]),
            "crc_refin": tk.BooleanVar(value=field.get("crc_custom", {}).get("refin", False)),
            "crc_refout": tk.BooleanVar(value=field.get("crc_custom", {}).get("refout", False)),
        }
        enum_dict = field.get("enum_dict", {}).copy()
        types_list = ["普通", "枚举", "CRC"]
        container = ttk.Frame(win)
        container.pack(fill="both", expand=True, padx=8, pady=8)
        container.columnconfigure(1, weight=1)
        ttk.Label(container, text="字段名称:").grid(row=0, column=0, sticky="w", padx=6, pady=6)
        ttk.Entry(container, textvariable=vars["name"]).grid(row=0, column=1, sticky="ew", padx=6)
        ttk.Label(container, text="字段类型:").grid(row=1, column=0, sticky="w", padx=6, pady=6)
        cb_type = ttk.Combobox(container, textvariable=vars["ftype"], values=types_list)
        cb_type.grid(row=1, column=1, sticky="ew", padx=6)
        row_len = 2
        lbl_len = ttk.Label(container, text="字节长度:")
        ent_len = ttk.Entry(container, textvariable=vars["length"])
        lbl_len.grid(row=row_len, column=0, sticky="w", padx=6, pady=6)
        ent_len.grid(row=row_len, column=1, sticky="ew", padx=6)
        ttk.Label(container, text="大小端:").grid(row=3, column=0, sticky="w", padx=6, pady=6)
        cb_endian = ttk.Combobox(container, values=["BE(大端)", "LE(小端)"], textvariable=vars["endian"])
        cb_endian.grid(row=3, column=1, sticky="ew", padx=6)
        var_frame = ttk.Frame(container)
        var_frame.grid(row=4, column=0, columnspan=2, sticky="ew", padx=4, pady=4)
        rb_fixed = ttk.Radiobutton(var_frame, text="定长字段", variable=vars["var_mode"], value="fixed", command=lambda: refresh_ui())
        rb_bind = ttk.Radiobutton(var_frame, text="不定长-绑定前置长度字段", variable=vars["var_mode"], value="bind", command=lambda: refresh_ui())
        rb_free = ttk.Radiobutton(var_frame, text="不定长-自由段(最多1个)", variable=vars["var_mode"], value="free", command=lambda: refresh_ui())
        rb_fixed.pack(side="left")
        rb_bind.pack(side="left", padx=4)
        rb_free.pack(side="left")
        bind_frame = ttk.Frame(container)
        bind_frame.grid(row=5, column=0, columnspan=2,
                        sticky="ew", padx=2, pady=2)
        ttk.Label(bind_frame, text="绑定长度字段:").pack(side="left")
        cb_bind = ttk.Combobox(bind_frame, textvariable=vars["bind_field"])
        cb_bind.pack(side="left", fill="x", expand=True, padx=4)
        bind_names = []
        if self.current_template:
            for idx, f in enumerate(self.current_template["fields"]):
                if index == -1 or idx < index:
                    bind_names.append(f["name"])
        cb_bind["values"] = bind_names
        cfg_frame = ttk.Frame(container)
        cfg_frame.grid(row=6, column=0, columnspan=2,
                       sticky="ew", padx=6, pady=6)

        def refresh_ui():
            tp = vars["ftype"].get()
            mode = vars["var_mode"].get()
            endian_combo = cb_endian
            if mode != "fixed":
                endian_combo.config(state="disabled")
            else:
                endian_combo.config(state="readonly")
            if tp == "普通":
                var_frame.grid()
                if mode == "fixed":
                    ent_len.config(state="normal")
                    bind_frame.grid_remove()
                elif mode == "bind":
                    ent_len.config(state="disabled")
                    bind_frame.grid()
                elif mode == "free":
                    ent_len.config(state="disabled")
                    bind_frame.grid_remove()
            else:
                vars["var_mode"].set("fixed")
                var_frame.grid_remove()
                bind_frame.grid_remove()
                ent_len.config(state="normal")
                endian_combo.config(state="readonly")
            for w in cfg_frame.winfo_children():
                w.destroy()
            if tp == "CRC":
                r = 0
                ttk.Label(cfg_frame, text="CRC模型:").grid(row=r, column=0, sticky="w", padx=6)
                cb_crc = ttk.Combobox(cfg_frame, textvariable=vars["crc_model"], values=CRC_MODEL_LIST)
                cb_crc.grid(row=r, column=1, sticky="ew", padx=6)
                r += 1
                ttk.Label(cfg_frame, text="校验起始字段:").grid(row=r, column=0, sticky="w", padx=6)
                cb_crc_start = ttk.Combobox(cfg_frame, textvariable=vars["crc_start_field"])
                crc_range_names = []
                if self.current_template:
                    for idx, f in enumerate(self.current_template["fields"]):
                        if index == -1 or idx < index:
                            crc_range_names.append(f["name"])
                cb_crc_start["values"] = [""] + crc_range_names
                cb_crc_start.grid(row=r, column=1, sticky="ew", padx=6)
                r += 1
                ttk.Label(cfg_frame, text="校验截止字段:").grid(row=r, column=0, sticky="w", padx=6)
                cb_crc_range = ttk.Combobox(cfg_frame, textvariable=vars["crc_range_field"])
                cb_crc_range["values"] = [""] + crc_range_names
                cb_crc_range.grid(row=r, column=1, sticky="ew", padx=6)
                r += 1
                custom_frame = ttk.Frame(cfg_frame)

                def refresh_crc_custom():
                    for w in custom_frame.winfo_children():
                        w.destroy()
                    if vars["crc_model"].get() == "自定义":
                        ttk.Label(custom_frame, text="宽度WIDTH").grid(row=0, column=0, sticky="w")
                        ttk.Entry(custom_frame, textvariable=vars["crc_width"]).grid(row=0, column=1, sticky="ew")
                        ttk.Label(custom_frame, text="POLY(Hex)").grid(row=1, column=0, sticky="w")
                        ttk.Entry(custom_frame, textvariable=vars["crc_poly"]).grid(row=1, column=1, sticky="ew")
                        ttk.Label(custom_frame, text="INIT(Hex)").grid(row=2, column=0, sticky="w")
                        ttk.Entry(custom_frame, textvariable=vars["crc_init"]).grid(row=2, column=1, sticky="ew")
                        ttk.Label(custom_frame, text="XOROUT(Hex)").grid(row=3, column=0, sticky="w")
                        ttk.Entry(custom_frame, textvariable=vars["crc_xorout"]).grid(row=3, column=1, sticky="ew")
                        ck1 = ttk.Checkbutton(custom_frame, text="REFIN", variable=vars["crc_refin"])
                        ck1.grid(row=4, column=0, sticky="w")
                        ck2 = ttk.Checkbutton(custom_frame, text="REFOUT", variable=vars["crc_refout"])
                        ck2.grid(row=4, column=1, sticky="w")
                cb_crc.bind("<<ComboboxSelected>>", lambda e: refresh_crc_custom())
                custom_frame.grid(row=r, column=0, columnspan=2, sticky="ew", padx=4, pady=4)
                refresh_crc_custom()
            elif tp == "枚举":
                top_bar = ttk.Frame(cfg_frame)
                top_bar.pack(fill="x")
                ttk.Label(top_bar, text="枚举列表(双击条目弹出编辑窗口)").pack(side="left")
                ttk.Button(top_bar, text="+", command=lambda: tree_enum.insert("", "end", values=("名称", "0x00"))).pack(side="right", padx=2)
                ttk.Button(top_bar, text="-", command=lambda: self.del_enum(tree_enum)).pack(side="right")
                tree_enum = ttk.Treeview(cfg_frame, columns=("name", "val"), show="headings", height=4)
                tree_enum.heading("name", text="枚举名称")
                tree_enum.heading("val", text="数值(0xXX)")
                tree_enum.pack(fill="x", padx=4)

                def enum_double_pop(event):
                    item = tree_enum.focus()
                    if not item:
                        return
                    vals = tree_enum.item(item, "values")
                    old_name, old_val = vals[0], vals[1]
                    dlg = tk.Toplevel(win)
                    dlg.title("修改枚举项")
                    dlg.geometry("320x160")
                    dlg.transient(win)
                    dlg.grab_set()
                    var_n = tk.StringVar(value=old_name)
                    var_v = tk.StringVar(value=old_val)
                    ttk.Label(dlg, text="枚举名称:").grid(row=0, column=0, padx=8, pady=8, sticky="w")
                    ttk.Entry(dlg, textvariable=var_n).grid(row=0, column=1, padx=8, pady=8, sticky="ew")
                    ttk.Label(dlg, text="枚举数值(Hex):").grid(row=1, column=0, padx=8, pady=8, sticky="w")
                    ttk.Entry(dlg, textvariable=var_v).grid(row=1, column=1, padx=8, pady=8, sticky="ew")

                    def confirm():
                        nn = var_n.get().strip()
                        nv = var_v.get().strip()
                        if not nn or not nv:
                            messagebox.showwarning("提示", "名称和数值不能为空", parent=dlg)
                            return
                        tree_enum.item(item, values=(nn, nv))
                        dlg.destroy()
                    ttk.Button(dlg, text="确定", command=confirm).grid(row=2, column=0, columnspan=2, pady=10)
                    dlg.columnconfigure(1, weight=1)
                tree_enum.bind("<Double-1>", enum_double_pop)
                for k, v in enum_dict.items():
                    tree_enum.insert("", "end", values=(k, f"0x{v:X}"))

                def save_enum():
                    nonlocal enum_dict
                    enum_dict.clear()
                    for it in tree_enum.get_children():
                        n, v = tree_enum.item(it, "values")
                        enum_dict[n] = int(v, 0)
                ttk.Button(cfg_frame, text="应用枚举列表", command=save_enum).pack()
        cb_type.bind("<<ComboboxSelected>>", lambda e: refresh_ui())
        refresh_ui()

        def confirm_save():
            try:
                w = int(vars["length"].get())
                var_mode = vars["var_mode"].get()
                new_fields = self.current_template["fields"].copy() if self.current_template else []
                if index >= 0:
                    new_fields.pop(index)
                free_cnt = sum(1 for f in new_fields if f.get("var_mode", "free") == "free")
                if var_mode == "free" and free_cnt >= 1:
                    messagebox.showerror("限制", "协议最多只能存在1个【不定长-自由段】")
                    return
                bind_name = vars["bind_field"].get().strip()
                if var_mode == "bind" and bind_name == "":
                    messagebox.showerror("参数错误", "绑定模式必须选择前置长度字段!")
                    return
                crc_custom = {
                    "width": int(vars["crc_width"].get()),
                    "poly": int(vars["crc_poly"].get(), 16) if vars["crc_poly"].get().strip() else 0,
                    "init": int(vars["crc_init"].get(), 16) if vars["crc_init"].get().strip() else 0,
                    "xorout": int(vars["crc_xorout"].get(), 16) if vars["crc_xorout"].get().strip() else 0,
                    "refin": vars["crc_refin"].get(),
                    "refout": vars["crc_refout"].get(),
                }
                new_field = {
                    "name": vars["name"].get().strip(),
                    "type": vars["ftype"].get(),
                    "length": w,
                    "var_mode": var_mode,
                    "bind_field": bind_name,
                    "endian": vars["endian"].get(),
                    "enum_dict": enum_dict.copy(),
                    "crc_model": vars["crc_model"].get().strip(),
                    "crc_start_field": vars["crc_start_field"].get(),
                    "crc_range_field": vars["crc_range_field"].get(),
                    "crc_custom": crc_custom
                }
                if index == -1:
                    self.current_template["fields"].append(new_field)
                else:
                    self.current_template["fields"][index] = new_field
                self.refresh_tree()
                self.build_form()
                win.destroy()
            except Exception as e:
                messagebox.showerror("参数错误", str(e))
        ttk.Button(container, text="确认保存字段", command=confirm_save).grid(row=7, column=0, columnspan=2, padx=14, pady=14)

    def del_field(self):
        sel = self.tree.selection()
        if not sel:
            return
        idx = self.tree.index(sel[0])
        del self.current_template["fields"][idx]
        self.refresh_tree()
        self.build_form()

    def clear_form(self):
        for w in self.form_frame.winfo_children():
            w.destroy()
        self.field_widgets = []
        self.var_hex.set("")

    def clear_input_values(self):
        for wdg in self.field_widgets:
            if "var" in wdg:
                wdg["var"].set("")

    def build_form(self):
        self.clear_form()
        if not self.current_template:
            return
        for fd in self.current_template["fields"]:
            fr = ttk.Frame(self.form_frame)
            fr.pack(anchor="w", padx=2, pady=2, fill="x")
            fr.columnconfigure(0, weight=0)
            fr.columnconfigure(1, weight=1)
            label_text = f"{fd['name']} [{fd['type']}]"
            if fd["type"] == "普通":
                label_text += "  十六进制空格分隔"
            lbl = ttk.Label(fr, text=label_text, width=68)
            lbl.grid(row=0, column=0, padx=10, sticky="w")
            ftype = fd["type"]
            enum_dict = fd.get("enum_dict", {})
            if ftype == "枚举":
                keys = list(enum_dict.keys())
                var = tk.StringVar(value=keys[0] if keys else "")
                cb = ttk.Combobox(fr, textvariable=var, values=keys)
                cb.grid(row=0, column=1, padx=100, pady=0)
                self.field_widgets.append(
                    {"field": fd, "var": var, "enum": enum_dict})
            elif ftype == "CRC":
                ttk.Label(fr, text="【自动计算】").grid(row=0, column=1)
                self.field_widgets.append({"field": fd})
            else:
                var = tk.StringVar()
                entry = ttk.Entry(fr, textvariable=var, width=30)
                entry.grid(row=0, column=1, padx=100)
                self.field_widgets.append({"field": fd, "var": var})

    def _get_raw_field_bytes(self, field_name):
        for wdg in self.field_widgets:
            fd = wdg["field"]
            if fd["name"] != field_name:
                continue
            tp = fd["type"]
            endian = "big" if fd["endian"] == "BE(大端)" else "little"
            if tp == "枚举":
                val = wdg["enum"][wdg["var"].get()]
                return val.to_bytes(fd["length"], byteorder=endian)
            elif tp == "普通":
                var_mode = fd.get("var_mode", "fixed")
                if var_mode == "fixed":
                    raw_str = wdg["var"].get().strip().replace(" ", "")
                    num = int(raw_str, 16) if raw_str else 0
                    return num.to_bytes(fd["length"], byteorder=endian)
                else:
                    raw = wdg["var"].get().strip()
                    return bytes.fromhex(raw) if raw else b""
        return b""

    def _get_bind_length_value(self, bind_field_name):
        try:
            target_fd = None
            for wdg in self.field_widgets:
                if wdg["field"]["name"] == bind_field_name:
                    target_fd = wdg["field"]
                    break
            if target_fd is None:
                return 0
            endian = "big" if target_fd["endian"] == "BE(大端)" else "little"
            data = self._get_raw_field_bytes(bind_field_name)
            val = int.from_bytes(data, byteorder=endian)
            return max(0, min(val, 4096))
        except Exception:
            return 0

    def _get_raw_undetermined_data(self, field_name):
        for wdg in self.field_widgets:
            fd = wdg["field"]
            if fd["name"] == field_name and fd["type"] == "普通":
                raw = wdg["var"].get().strip()
                if raw:
                    return bytes.fromhex(raw)
                return b""
        return b""

    def build_frame(self):
        try:
            final_buf = b""
            for wdg in self.field_widgets:
                fd = wdg["field"]
                tp = fd["type"]
                endian = "big" if fd["endian"] == "BE(大端)" else "little"
                var_mode = fd.get("var_mode", "fixed")
                if tp == "枚举":
                    val = wdg["enum"][wdg["var"].get()]
                    final_buf += val.to_bytes(fd["length"], byteorder=endian)
                elif tp == "普通":
                    if var_mode == "fixed":
                        bdata = self._get_raw_field_bytes(fd["name"])
                        final_buf += bdata
                    elif var_mode == "bind":
                        bind_len = self._get_bind_length_value(fd["bind_field"])
                        raw_data = self._get_raw_undetermined_data(fd["name"])
                        chunk = raw_data[:bind_len]
                        if len(chunk) < bind_len:
                            chunk = raw_data[:bind_len].ljust(bind_len, b"\x00")
                        final_buf += chunk
                    elif var_mode == "free":
                        raw = wdg["var"].get().strip()
                        chunk = bytes.fromhex(raw) if raw else b""
                        final_buf += chunk
                elif tp == "CRC":
                    crc_start_name = fd.get("crc_start_field", "")
                    crc_end_name = fd.get("crc_range_field", "")
                    start_ptr = 0
                    end_ptr = len(final_buf)
                    if crc_start_name:
                        pos = 0
                        for f_wdg in self.field_widgets:
                            f = f_wdg["field"]
                            if f["name"] == crc_start_name:
                                start_ptr = pos
                                break
                            if f["type"] == "CRC":
                                pos += f["length"]
                            elif f.get("var_mode") == "bind":
                                pos += self._get_bind_length_value(f["bind_field"])
                            elif f.get("free"):
                                pos += 4096
                            else:
                                pos += f["length"]
                    if crc_end_name:
                        pos = 0
                        for f_wdg in self.field_widgets:
                            f = f_wdg["field"]
                            pos += self._get_field_actual_length(f_wdg)
                            if f["name"] == crc_end_name:
                                end_ptr = pos
                                break
                    calc_data = final_buf[start_ptr:end_ptr]
                    if fd["crc_model"] == "自定义":
                        cfg = fd["crc_custom"]
                        crc_cfg = {"width": cfg["width"], "poly": cfg["poly"], "init": cfg["init"],
                                   "xorout": cfg["xorout"], "refin": cfg["refin"], "refout": cfg["refout"]}
                    else:
                        crc_cfg = DEFAULT_PRESETS[fd["crc_model"]]
                    crc_val = calc_crc(calc_data, **crc_cfg)
                    # CRC计算得到的整数,固定大端输出,refin/refout已经处理比特反转
                    crc_bytes = crc_val.to_bytes(fd["length"], byteorder="big")
                    # 如果模板字段配置是小端,对crc字节做字节反转
                    if endian == "little":
                        crc_bytes = crc_bytes[::-1]
                    final_buf += crc_bytes
            hex_out = " ".join(f"{b:02X}" for b in final_buf)
            self.var_hex.set(hex_out)
            return hex_out
        except ValueError as ve:
            messagebox.showerror("组帧失败", f"十六进制格式错误:{str(ve)}")
            return ""
        except Exception as e:
            messagebox.showerror("组帧失败", str(e))
            return ""

    def _get_field_actual_length(self, wdg):
        fd = wdg["field"]
        vm = fd.get("var_mode", "fixed")
        if vm == "bind":
            return self._get_bind_length_value(fd["bind_field"])
        elif vm == "free":
            return 4096
        else:
            return fd["length"]

    def parse_frame(self):
        hex_str = self.var_hex.get().strip()
        if not hex_str:
            messagebox.showwarning("提示", "请粘贴十六进制报文")
            return
        try:
            data = bytes.fromhex(hex_str)
            ptr = 0
            for wdg in self.field_widgets:
                fd = wdg["field"]
                tp = fd["type"]
                var_mode = fd.get("var_mode", "fixed")
                endian = "big" if fd["endian"] == "BE(大端)" else "little"
                if tp == "CRC":
                    ptr += fd["length"]
                    continue
                if var_mode == "fixed":
                    if ptr + fd["length"] > len(data):
                        messagebox.showwarning("长度不足", f"{fd['name']}超出报文")
                        break
                    chunk = data[ptr:ptr+fd["length"]]
                    ptr += fd["length"]
                    if tp == "枚举":
                        val = int.from_bytes(chunk, byteorder=endian)
                        rev = {v: k for k, v in wdg["enum"].items()}
                        wdg["var"].set(rev.get(val, ""))
                    else:
                        chunk = chunk[::-1] if endian == "little" else chunk
                        hex_str_out = " ".join(f"{b:02X}" for b in chunk)
                        wdg["var"].set(hex_str_out)
                elif var_mode == "bind":
                    blen = self._get_bind_length_value(fd["bind_field"])
                    if ptr + blen > len(data):
                        messagebox.showwarning("长度不足", f"{fd['name']}超出报文")
                        break
                    chunk = data[ptr:ptr+blen]
                    ptr += blen
                    wdg["var"].set(" ".join(f"{b:02X}" for b in chunk))
                elif var_mode == "free":
                    chunk = data[ptr:]
                    ptr = len(data)
                    wdg["var"].set(" ".join(f"{b:02X}" for b in chunk))
        except Exception as e:
            messagebox.showerror("解析异常", str(e))

# ===================== Tab1 常用报文库(重构:删除分类+弹窗一体化) =====================

class MessageLibTab:
    def __init__(self, parent, notify_template_change, switch_tab, editor_ref):
        self.parent = parent
        self.notify_refresh = notify_template_change
        self.switch_tab = switch_tab
        self.editor = editor_ref
        self.msg_list = []
        self.category_list = []
        self.load_file()
        self.build_ui()

    def load_file(self):
        if os.path.exists(MSG_LIB_FILE):
            try:
                with open(MSG_LIB_FILE, "r", encoding="utf-8") as f:
                    self.msg_list = json.load(f)
            except:
                self.msg_list = []
        if os.path.exists(MSG_CATEGORY_FILE):
            try:
                with open(MSG_CATEGORY_FILE, "r", encoding="utf-8") as f:
                    self.category_list = json.load(f)
            except:
                self.category_list = ["默认分类"]
        else:
            self.category_list = ["默认分类"]

    def save_msg_lib(self):
        with open(MSG_LIB_FILE, "w", encoding="utf-8") as f:
            json.dump(self.msg_list, f, ensure_ascii=False, indent=2)
        self.refresh_msg_tree()

    def save_category_file(self):
        with open(MSG_CATEGORY_FILE, "w", encoding="utf-8") as f:
            json.dump(self.category_list, f, ensure_ascii=False, indent=2)
        self.refresh_category_cmb()

    def refresh_category_cmb(self):
        self.cmb_category["values"] = self.category_list

    def refresh_template_cmb(self, editor):
        if editor and editor.templates:
            self.cmb_tmpl["values"] = [t["name"] for t in editor.templates]
        else:
            self.cmb_tmpl["values"] = []

    def refresh_msg_tree(self):
        for i in self.tree.get_children():
            self.tree.delete(i)
        for idx, item in enumerate(self.msg_list):
            self.tree.insert("", "end", iid=str(idx), values=(item["category"], item["msg_name"], item["template_name"], item["hex_data"], item["remark"]))

    def build_ui(self):
        top_frame = ttk.Frame(self.parent)
        top_frame.pack(fill="x", padx=5, pady=5)
        ttk.Label(top_frame, text="报文分类:").pack(side="left")
        self.cmb_category = ttk.Combobox(top_frame, width=15, values=self.category_list)
        self.cmb_category.set(self.category_list[0])
        self.cmb_category.pack(side="left", padx=4)
        ttk.Button(top_frame, text="新增分类", command=self.add_category).pack(side="left", padx=2)
        ttk.Button(top_frame, text="删除分类", command=self.del_category).pack(side="left", padx=2)
        ttk.Label(top_frame, text="选择模板:").pack(side="left", padx=10)
        self.cmb_tmpl = ttk.Combobox(top_frame, width=25)
        self.cmb_tmpl.pack(side="left", padx=4)
        mid_frame = ttk.LabelFrame(self.parent, text="新建报文(选择模板后点击配置)")
        mid_frame.pack(fill="x", padx=5, pady=5)
        ttk.Button(mid_frame, text="配置报文并保存入库", command=lambda: self.popup_build_full_msg(edit_item=None)).grid(row=0, column=0, padx=8, pady=8)
        list_frame = ttk.LabelFrame(self.parent, text="全部报文列表")
        list_frame.pack(fill="both", expand=True, padx=5, pady=5)
        self.tree = ttk.Treeview(list_frame, columns=("cat", "name", "tmpl", "hex", "remark"), show="headings")
        self.tree.heading("cat", text="分类")
        self.tree.heading("name", text="报文名")
        self.tree.heading("tmpl", text="模板")
        self.tree.heading("hex", text="报文Hex")
        self.tree.heading("remark", text="备注")
        self.tree.column("hex", width=320)
        self.tree.pack(fill="both", expand=True)
        btn_bar = ttk.Frame(list_frame)
        btn_bar.pack(fill="x", pady=3)
        ttk.Button(btn_bar, text="删除选中", command=self.del_msg).pack(side="left")
        ttk.Button(btn_bar, text="复制Hex", command=self.copy_hex).pack(side="left", padx=5)
        self.tree.bind("<Double-1>", self.load_selected_msg)
        self.refresh_category_cmb()
        self.refresh_msg_tree()

    def del_category(self):
        cat = self.cmb_category.get().strip()
        if cat == "默认分类":
            messagebox.showerror("禁止操作", "默认分类不能删除")
            return
        if cat not in self.category_list:
            return
        res = messagebox.askyesno("确认删除", f"确定删除分类【{cat}】?该分类下所有报文会丢失!")
        if not res:
            return
        # 清除该分类所有报文
        self.msg_list = [m for m in self.msg_list if m["category"] != cat]
        self.category_list.remove(cat)
        self.save_category_file()
        self.save_msg_lib()
        messagebox.showinfo("完成", "分类已删除")

    def add_category(self):
        cat = simpledialog.askstring("新增分类", "输入分类名称")
        if not cat or cat.strip() == "" or cat in self.category_list:
            return
        self.category_list.append(cat)
        self.save_category_file()

    def popup_build_full_msg(self, edit_item=None):
        tmpl_name = self.cmb_tmpl.get().strip()
        target_cat = self.cmb_category.get().strip()
        edit_data = None
        # 如果是编辑模式,覆盖原有模板、分类
        if edit_item is not None:
            edit_data = edit_item
            tmpl_name = edit_item["template_name"]
            target_cat = edit_item["category"]
            self.cmb_tmpl.set(tmpl_name)
            self.cmb_category.set(target_cat)
        if not tmpl_name:
            messagebox.showwarning("提示", "请先选择协议", parent=self.parent)
            return
        # 找到对应模板
        target_tmpl = None
        for t in self.editor.templates:
            if t["name"] == tmpl_name:
                target_tmpl = t
                break
        if target_tmpl is None:
            messagebox.showerror("错误", "模板不存在,请先在Tab0配置模板")
            return
        # 创建弹窗
        win = tk.Toplevel(self.parent.winfo_toplevel())
        win.title("配置报文并保存入库" if edit_item is None else "编辑报文")
        win.geometry("750x620")
        win.transient(self.parent.winfo_toplevel())
        win.grab_set()
        win.columnconfigure(1, weight=1)
        vars_field = {}
        # 1. 报文名称、备注
        top_cfg = ttk.LabelFrame(win, text="报文基础信息")
        top_cfg.pack(fill="x", padx=6, pady=6)
        ttk.Label(top_cfg, text="报文名称:").grid(row=0, column=0, sticky="w", padx=4, pady=6)
        var_msg_name = tk.StringVar()
        if edit_data is not None:
            var_msg_name.set(edit_data["msg_name"])
        ttk.Entry(top_cfg, textvariable=var_msg_name, width=22).grid(row=0, column=1, padx=4)
        ttk.Label(top_cfg, text="用途备注:").grid(row=0, column=2, sticky="w", padx=10)
        var_remark = tk.StringVar()
        if edit_data is not None:
            var_remark.set(edit_data["remark"])
        ttk.Entry(top_cfg, textvariable=var_remark, width=38).grid(row=0, column=3, padx=4)

        # 2. 字段输入区
        field_frame = ttk.LabelFrame(win, text="协议字段填写(十六进制)")
        field_frame.pack(fill="both", expand=True, padx=6, pady=6)
        r_idx = 0
        if edit_item is not None and "field_input" in edit_item:
            saved_input = edit_item["field_input"]
        else:
            saved_input = {}
        for fd in target_tmpl["fields"]:
            fname = fd["name"]
            ftype = fd["type"]
            ttk.Label(field_frame, text=f"{fname} [{ftype}]").grid(row=r_idx, column=0, sticky="w", padx=3, pady=4)
            if ftype == "枚举":
                enum_map = fd["enum_dict"]
                init_val = saved_input.get(fname, list(enum_map.keys())[0] if enum_map else "")
                var_f = tk.StringVar(value=init_val)
                cb_f = ttk.Combobox(field_frame, textvariable=var_f, values=list(enum_map.keys()), width=28)
                cb_f.grid(row=r_idx, column=1, padx=3)
            elif ftype == "CRC":
                var_f = tk.StringVar()
                ttk.Label(field_frame, text="自动计算无需填写").grid(row=r_idx, column=1)
            else:
                var_f = tk.StringVar()
                ent_f = ttk.Entry(field_frame, textvariable=var_f, width=32)
                ent_f.grid(row=r_idx, column=1, padx=3)
                if fname in saved_input:
                    var_f.set(saved_input[fname])
            vars_field[fname] = {"var": var_f, "field": fd}
            r_idx += 1
        # 底部按钮
        btn_frame = ttk.Frame(win)
        btn_frame.pack(fill="x", padx=6, pady=8)

        def save_all():
            try:
                msg_name = var_msg_name.get().strip()
                remark = var_remark.get().strip()
                if not msg_name:
                    messagebox.showwarning("提示", "报文名称不能为空", parent=win)
                    return

                field_input_save = {}
                temp_fields = []
                # 1. 先把弹窗字段数据缓存,复刻编辑器field_widgets结构
                for fd in target_tmpl["fields"]:
                    fn = fd["name"]
                    fdata = vars_field[fn]
                    v = fdata["var"].get().strip()
                    temp_fields.append((fd, v))
                    if fd["type"] != "CRC":
                        field_input_save[fn] = v
                final_buf = b""
                crc_cache = {}

                # 2. 完全复刻编辑器遍历逻辑,逐字段流式拼接
                for fd, v in temp_fields:
                    tp = fd["type"]
                    endian = "big" if fd["endian"] == "BE(大端)" else "little"
                    var_mode = fd.get("var_mode", "fixed")
                    if tp == "枚举":
                        val = fd["enum_dict"][v]
                        final_buf += val.to_bytes(fd["length"], byteorder=endian)
                    elif tp == "普通":
                        if var_mode == "fixed":
                            raw_str = v.replace(" ", "")
                            num = int(raw_str, 16) if raw_str else 0
                            bdata = num.to_bytes(fd["length"], byteorder=endian)
                            final_buf += bdata
                        elif var_mode == "bind":
                            bind_fd = None
                            bind_val_str = ""
                            for f, vv in temp_fields:
                                if f["name"] == fd["bind_field"]:
                                    bind_fd = f
                                    bind_val_str = vv.replace(" ", "")
                                    break
                            bind_len = int(bind_val_str, 16) if bind_val_str else 0
                            bind_len = min(bind_len, 4096)
                            raw_data = bytes.fromhex(v.replace(" ", "")) if v else b""
                            chunk = raw_data[:bind_len]
                            if len(chunk) < bind_len:
                                chunk = chunk.ljust(bind_len, b"\x00")
                            final_buf += chunk
                        elif var_mode == "free":
                            raw = bytes.fromhex(v.replace(" ", "")) if v else b""
                            final_buf += raw
                    elif tp == "CRC":
                        crc_start_name = fd.get("crc_start_field", "")
                        crc_end_name = fd.get("crc_range_field", "")
                        start_ptr = 0
                        end_ptr = len(final_buf)
                        if crc_start_name:
                            pos = 0
                            for f, vv in temp_fields:
                                if f["name"] == crc_start_name:
                                    start_ptr = pos
                                    break
                                if f["type"] == "CRC":
                                    pos += f["length"]
                                elif f.get("var_mode") == "bind":
                                    # 计算bind真实长度
                                    bv = ""
                                    for ff, vvv in temp_fields:
                                        if ff["name"] == f["bind_field"]:
                                            bf = ff
                                            bv = vvv
                                            break
                                    blen = int(bv.replace(" ", ""), 16) if bv else 0
                                    pos += min(blen, 4096)
                                elif f.get("var_mode") == "free":
                                    pos += 4096
                                else:
                                    pos += f["length"]
                        if crc_end_name:
                            pos = 0
                            for f, vv in temp_fields:
                                flen = 0
                                ftp = f["type"]
                                fvm = f.get("var_mode", "fixed")
                                if ftp == "CRC":
                                    flen = f["length"]
                                elif fvm == "bind":
                                    bf = None
                                    bv = ""
                                    for ff, vvv in temp_fields:
                                        if ff["name"] == f["bind_field"]:
                                            bv = vvv
                                            break
                                    blen = int(bv.replace(" ", ""), 16) if bv else 0
                                    flen = min(blen, 4096)
                                elif fvm == "free":
                                    flen = 4096
                                else:
                                    flen = f["length"]
                                pos += flen
                                if f["name"] == crc_end_name:
                                    end_ptr = pos
                                    break
                        calc_data = final_buf[start_ptr:end_ptr]
                        if fd["crc_model"] == "自定义":
                            crc_cfg = fd["crc_custom"]
                        else:
                            crc_cfg = DEFAULT_PRESETS[fd["crc_model"]]
                        crc_val = calc_crc(calc_data, **crc_cfg)
                        crc_bytes = crc_val.to_bytes(fd["length"], byteorder="big")
                        if endian == "little":
                            crc_bytes = crc_bytes[::-1]
                        crc_cache[fd["name"]] = crc_bytes
                        final_buf += crc_bytes

                hex_str = " ".join(f"{b:02X}" for b in final_buf)

                if edit_item is None:
                    for m in self.msg_list:
                        if m["category"] == target_cat and m["msg_name"] == msg_name:
                            messagebox.showerror("重名", "当前分类下该报文名称已存在", parent=win)
                            return
                    self.msg_list.append({
                        "category": target_cat,
                        "msg_name": msg_name,
                        "template_name": tmpl_name,
                        "hex_data": hex_str,
                        "remark": remark,
                        "field_input": field_input_save
                    })
                else:
                    edit_idx = self.msg_list.index(edit_item)
                    for idx, m in enumerate(self.msg_list):
                        if idx != edit_idx and m["category"] == target_cat and m["msg_name"] == msg_name:
                            messagebox.showerror("重名", "当前分类下该报文名称已存在", parent=win)
                            return
                    self.msg_list[edit_idx] = {
                        "category": target_cat,
                        "msg_name": msg_name,
                        "template_name": tmpl_name,
                        "hex_data": hex_str,
                        "remark": remark,
                        "field_input": field_input_save
                    }
                self.save_msg_lib()
                self.refresh_msg_tree()
                win.destroy()
            except Exception as err:
                import traceback
                err_info = traceback.format_exc()
                messagebox.showerror("生成失败", f"错误详情:{err_info}", parent=win)
        ttk.Button(btn_frame, text="生成并保存报文", command=save_all).pack(side="left", padx=15)
        ttk.Button(btn_frame, text="取消", command=win.destroy).pack(side="left")

    def del_msg(self):
        sel = self.tree.selection()
        if not sel:
            return
        idx = int(sel[0])
        del self.msg_list[idx]
        self.save_msg_lib()

    def copy_hex(self):
        sel = self.tree.selection()
        if not sel:
            return
        idx = int(sel[0])
        hex_txt = self.msg_list[idx]["hex_data"]
        self.parent.clipboard_clear()
        self.parent.clipboard_append(hex_txt)

    def load_selected_msg(self, event):
        sel = self.tree.selection()
        if not sel:
            return
        idx = int(sel[0])
        item = self.msg_list[idx]
        # 双击直接打开编辑弹窗,传入旧报文对象
        self.popup_build_full_msg(edit_item=item)
# ===================== Tab2 通信发送控制台 =====================

class CommSendTab:
    def __init__(self, parent, get_msg_lib):
        self.parent = parent
        self.get_msg_lib = get_msg_lib
        self.ser = None
        self.can_bus = None
        self.timer_id = None
        self.send_thread = None
        self.thread_run = False
        self.send_list = []
        self.load_comm_cfg()

        # 筛选下拉变量
        self.send_cat_var = tk.StringVar()
        self.cmb_send_cat = None
        self.send_msg_var = tk.StringVar()
        self.cmb_send_msg = None

        # 串口变量
        self.ser_port_var = tk.StringVar()
        self.ser_baud_var = tk.StringVar()
        self.ser_databit_var = tk.StringVar()
        self.ser_stopbit_var = tk.StringVar()
        self.ser_parity_var = tk.StringVar()
        self.ser_recv_thread = None
        self.ser_recv_run = False

        # CAN变量
        self.can_chan_var = tk.StringVar()
        self.can_interface_var = tk.StringVar(value="pcan")
        self.can_arb_baud_var = tk.StringVar()
        self.can_fd_var = tk.StringVar()
        self.can_fd_en_var = tk.BooleanVar()
        self.can_ext_var = tk.BooleanVar()
        self.can_txid_var = tk.StringVar()
        self.can_mode_var = tk.StringVar(value="CAN2.0")
        self.can_dlc_var = tk.StringVar(value="8")
        self.dlc_list = [0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 20, 24, 32, 48, 64]
        self.cb_dlc = None
        self.can_recv_thread = None
        self.can_recv_run = False

        # USB‑CDC
        self.cdc_ser = None
        self.cdc_port_var = tk.StringVar()
        self.cdc_baud_var = tk.StringVar()
        self.cdc_databit_var = tk.StringVar()
        self.cdc_stopbit_var = tk.StringVar()
        self.cdc_parity_var = tk.StringVar()
        self.cdc_recv_thread = None
        self.cdc_recv_run = False

        # USB-HID
        self.usb_vid_var = tk.StringVar()
        self.usb_pid_var = tk.StringVar()
        self.usb_rlen_var = tk.StringVar()
        self.usb_dev = None
        self.usb_recv_thread = None
        self.usb_recv_run = False

        self.send_tree = None
        self.enable_recv_log = tk.BooleanVar(value=True)

        self.build_ui()
        self.refresh_send_tree()

    def load_comm_cfg(self):
        if os.path.exists(COMM_CFG_FILE):
            try:
                with open(COMM_CFG_FILE, "r", encoding="utf-8") as f:
                    self.cfg = json.load(f)
            except:
                self.cfg = {}
        else:
            self.cfg = {
                "active_comm": "串口(TTL/RS232)",
                "serial": {"port": "", "baud": 115200, "data": 8, "stop": 1, "parity": "N"},
                "can": {"channel": "0", "arb_baud": 500000, "fd_baud": 2000000, "fd_enable": False, "ext": False, "tx_id": 0x7FF, "interface": "pcan"},
                "usb_cdc": {"port": "", "baud": 115200, "data": 8, "stop": 1, "parity": "N"},
                "usb_vid": {"vid": 0, "pid": 0, "report_len": 64},
                "period_ms": 1000,
                "global_auto": False
            }

    def save_cfg(self):
        with open(COMM_CFG_FILE, "w", encoding="utf-8") as f:
            json.dump(self.cfg, f, indent=2)

    def double_click_edit_period(self, event=None):
        sel = self.send_tree.selection()
        if not sel:
            return
        idx = int(sel[0])
        item = self.send_list[idx]
        win = tk.Toplevel(self.parent.winfo_toplevel())
        win.title("修改发送周期")
        win.geometry("400x160")
        win.transient(self.parent)
        win.grab_set()
        win.columnconfigure(1, weight=1)
        ttk.Label(win, text=f"报文:{item['name']}").grid(row=0, column=0, columnspan=2, pady=8)
        ttk.Label(win, text="周期ms:").grid(row=1, column=0, sticky="w", padx=10)
        var_p = tk.StringVar(value=str(item["period"]))
        ttk.Entry(win, textvariable=var_p, width=20).grid(row=1, column=1, padx=10)

        def save():
            try:
                new_p = int(var_p.get())
            except ValueError:
                messagebox.showerror("输入错误", "请输入数字", parent=win)
                return
            item["period"] = new_p
            self.refresh_send_tree()
            win.destroy()

        ttk.Button(win, text="保存", command=save).grid(row=2, column=0, columnspan=2, pady=12)

    def refresh_msg_cmb(self, event=None):
        lib = self.get_msg_lib()
        target_cat = self.send_cat_var.get()
        if not target_cat:
            self.cmb_send_msg["values"] = []
            self.send_msg_var.set("")
            self.cmb_send_msg.config(state="disabled")
            return
        msg_names = [m["msg_name"] for m in lib.msg_list if m["category"] == target_cat]
        self.cmb_send_msg["values"] = msg_names
        self.cmb_send_msg.config(state="readonly")

    def refresh_cat_cmb(self):
        lib = self.get_msg_lib()
        cats = lib.category_list
        self.cmb_send_cat["values"] = cats
        self.send_cat_var.set("")
        self.send_msg_var.set("")
        self.cmb_send_msg["values"] = []
        self.cmb_send_msg.config(state="disabled")

    def add_msg_from_select(self):
        lib = self.get_msg_lib()
        sel_cat = self.send_cat_var.get()
        sel_msg = self.send_msg_var.get()
        if not sel_msg:
            messagebox.showwarning("提示", "请选择报文")
            return
        # 查找报文
        target = None
        for m in lib.msg_list:
            if m["category"] == sel_cat and m["msg_name"] == sel_msg:
                target = m
                break
        if target is None:
            return
        win = tk.Toplevel(self.parent.winfo_toplevel())
        win.title("添加发送报文")
        win.geometry("420x180")
        win.transient(self.parent)
        win.grab_set()
        win.columnconfigure(1, weight=1)
        ttk.Label(win, text=f"选中报文:{sel_msg}", font=("SimSun", 11)).grid(row=0, column=0, columnspan=2, pady=10)
        ttk.Label(win, text="发送周期(ms,0仅手动):").grid(row=1, column=0, sticky="w", padx=10)
        var_per = tk.StringVar(value="1000")
        ttk.Entry(win, textvariable=var_per, width=18).grid(row=1, column=1, padx=10)

        def confirm():
            try:
                per = int(var_per.get())
            except:
                messagebox.showerror("错误", "周期必须是数字", parent=win)
                return
            self.send_list.append({
                "name": sel_msg,
                "hex": target["hex_data"],
                "period": per,
                "enable": True
            })
            self.refresh_send_tree()
            win.destroy()

        ttk.Button(win, text="确认添加", command=confirm).grid(row=2, column=0, columnspan=2, pady=15)
    def build_ui(self):
        # 顶部通道选择行
        top_sel = ttk.Frame(self.parent)
        top_sel.pack(fill="x", padx=5, pady=5)
        ttk.Label(top_sel, text="通信通道切换:").pack(side="left")
        self.comm_type_var = tk.StringVar(value=self.cfg["active_comm"])
        self.comm_sel = ttk.Combobox(top_sel, values=["串口(TTL/RS232)", "CAN", "USB‑CDC", "USB-HID"], textvariable=self.comm_type_var, state="readonly")
        self.comm_sel.pack(side="left", padx=5)
        self.comm_sel.bind("<<ComboboxSelected>>", self.on_comm_switch)

        # 独立参数配置容器
        self.comm_param_frame = ttk.LabelFrame(self.parent, text="通道参数配置")
        self.comm_param_frame.pack(fill="x", padx=5, pady=5)

        filter_frame = ttk.LabelFrame(self.parent, text="报文快速选择")
        filter_frame.pack(fill="x", padx=5, pady=5)
        ttk.Label(filter_frame, text="分类:").grid(row=0, column=0, padx=3, pady=4)
        self.send_cat_var = tk.StringVar()
        self.cmb_send_cat = ttk.Combobox(filter_frame, textvariable=self.send_cat_var, state="readonly", width=16)
        self.cmb_send_cat.grid(row=0, column=1, padx=3)
        self.cmb_send_cat.bind("<<ComboboxSelected>>", self.refresh_msg_cmb)

        ttk.Label(filter_frame, text="报文:").grid(row=0, column=2, padx=8, pady=4)
        self.send_msg_var = tk.StringVar()
        self.cmb_send_msg = ttk.Combobox(filter_frame, textvariable=self.send_msg_var, state="disabled", width=30)
        self.cmb_send_msg.grid(row=0, column=3, padx=3)

        ttk.Button(filter_frame, text="添加到发送列表", command=self.add_msg_from_select).grid(row=0, column=4, padx=10)

        # 待发送报文列表
        list_frame = ttk.LabelFrame(self.parent, text="待发送报文列表(双击修改周期)")
        list_frame.pack(fill="both", expand=True, padx=5, pady=5)
        btn_list = ttk.Frame(list_frame)
        btn_list.pack(fill="x", padx=3, pady=3)
        ttk.Button(btn_list, text="编辑选中", command=self.edit_send_item).pack(side="left")
        ttk.Button(btn_list, text="删除选中", command=self.del_send_item).pack(side="left", padx=3)
        ttk.Button(btn_list, text="清空列表", command=self.clear_send_list).pack(side="left")
        self.send_tree = ttk.Treeview(list_frame, columns=("name", "hex", "period", "enable"), show="headings")
        self.send_tree.heading("name", text="报文名称")
        self.send_tree.heading("hex", text="Hex载荷")
        self.send_tree.heading("period", text="周期ms(0=仅手动)")
        self.send_tree.heading("enable", text="周期启用")
        self.send_tree.column("hex", width=360)
        self.send_tree.pack(fill="both", expand=True)

        # 双击绑定:打开修改周期弹窗
        self.send_tree.bind("<Double-1>", self.double_click_edit_period)

        # 全局发送控制
        ctrl_frame = ttk.LabelFrame(self.parent, text="全局发送控制")
        ctrl_frame.pack(fill="x", padx=5, pady=5)
        self.var_global_auto = tk.BooleanVar(value=self.cfg["global_auto"])
        ttk.Checkbutton(ctrl_frame, text="全局启动周期发送", variable=self.var_global_auto,
                        command=self.toggle_thread).pack(side="left", padx=5)
        ttk.Button(ctrl_frame, text="发送选中报文(单次)", command=self.send_single).pack(side="left", padx=10)
        self.status_var = tk.StringVar(value="通道:未打开")
        ttk.Label(ctrl_frame, textvariable=self.status_var).pack(side="right", padx=10)

        # 收发日志
        log_frame = ttk.LabelFrame(self.parent, text="收发日志")
        log_frame.pack(fill="both", expand=True, padx=5, pady=5)
        log_ctrl = ttk.Frame(log_frame)
        log_ctrl.pack(fill="x")
        ttk.Checkbutton(log_frame, text="打印接收数据日志", variable=self.enable_recv_log).pack(side="left")
        ttk.Button(log_frame, text="清空日志", command=lambda: self.log_text.delete(1.0, tk.END)).pack(anchor="w")
        self.log_text = scrolledtext.ScrolledText(log_frame, height=10)
        self.log_text.pack(fill="both", expand=True)

        # 初始化下拉数据
        self.refresh_cat_cmb()
        self.refresh_send_tree()
        self.on_comm_switch()

    def on_can_mode_change(self, event=None):
        mode = self.can_mode_var.get()
        if mode == "CAN-FD":
            self.can_fd_en_var.set(True)
        else:
            self.can_fd_en_var.set(False)
        self.fd_switch()
        # 同步过滤DLC
        if mode == "CAN-FD":
            self.cb_dlc["values"] = [str(x) for x in self.dlc_list]
        else:
            self.cb_dlc["values"] = [str(x) for x in self.dlc_list if x <= 8]
            if int(self.can_dlc_var.get()) > 8:
                self.can_dlc_var.set("8")

    def on_comm_switch(self, event=None):
        if self.ser and self.ser.is_open:
            self.ser_recv_run = False
            if self.ser_recv_thread is not None:
                self.ser_recv_thread.join(0.2)
            self.ser.close()
            self.ser = None
            self.log("切换通道,串口已关闭")
        if self.cdc_ser and self.cdc_ser.is_open:
            self.cdc_recv_run = False
            if self.cdc_recv_thread is not None:
                self.cdc_recv_thread.join(0.2)
            self.cdc_ser.close()
            self.cdc_ser = None
            self.log("切换通道,USB‑CDC已关闭")
        if self.can_bus is not None:
            self.can_recv_run = False
            if self.can_recv_thread:
                self.can_recv_thread.join(0.2)
            self.can_bus.shutdown()
            self.can_bus = None
            self.log("切换通道,CAN通道已关闭")
        if self.usb_dev is not None:
            self.usb_recv_run = False
            if self.usb_recv_thread:
                self.usb_recv_thread.join(0.2)
            try:
                self.usb_dev.close()
            except Exception:
                pass
            self.usb_dev = None
            self.log("切换通道,USB‑HID已断开")
        self.status_var.set("通道:未打开")
        # 清空参数面板所有旧控件
        for widget in self.comm_param_frame.winfo_children():
            widget.destroy()
        # 根据选择渲染对应面板,传入父容器
        sel = self.comm_sel.get()
        if sel == "串口(TTL/RS232)":
            self.build_serial_panel(parent=self.comm_param_frame)
        elif sel == "CAN":
            self.build_can_panel(parent=self.comm_param_frame)
        elif sel == "USB‑CDC":
            self.build_usb_cdc_panel(parent=self.comm_param_frame)
        elif sel == "USB-HID":
            self.build_usb_panel(parent=self.comm_param_frame)

    def build_serial_panel(self, parent):
        f = parent
        for w in f.winfo_children():
            w.destroy()
        ser_cfg = self.cfg["serial"]
        self.ser_port_var.set(ser_cfg["port"])
        self.ser_baud_var.set(str(ser_cfg["baud"]))
        self.ser_databit_var.set(str(ser_cfg["data"]))
        self.ser_stopbit_var.set(str(ser_cfg["stop"]))
        self.ser_parity_var.set(ser_cfg["parity"])

        row = 0
        ttk.Label(f, text="串行COM端口:").grid(row=row, column=0, sticky="w", padx=3, pady=4)
        self.cmb_serial_port = ttk.Combobox(f, textvariable=self.ser_port_var, width=15, state="readonly")
        self.cmb_serial_port.grid(row=row, column=1, padx=3)
        ttk.Button(f, text="刷新端口", command=self.refresh_serial_ports).grid(row=row, column=2)
        row += 1
        ttk.Label(f, text="波特率:").grid(row=row, column=0, sticky="w", padx=3, pady=4)
        ttk.Combobox(f, textvariable=self.ser_baud_var, values=[str(x) for x in SERIAL_BAUDS], state="readonly").grid(row=row, column=1, padx=3)
        row += 1
        ttk.Label(f, text="数据位:").grid(row=row, column=0, sticky="w", padx=3, pady=4)
        ttk.Combobox(f, textvariable=self.ser_databit_var, values=[str(x) for x in SERIAL_DATA], state="readonly").grid(row=row, column=1, padx=3)
        row += 1
        ttk.Label(f, text="停止位:").grid(row=row, column=0, sticky="w", padx=3, pady=4)
        ttk.Combobox(f, textvariable=self.ser_stopbit_var, values=[str(x) for x in SERIAL_STOP], state="readonly").grid(row=row, column=1, padx=3)
        row += 1
        ttk.Label(f, text="校验位:").grid(row=row, column=0, sticky="w", padx=3, pady=4)
        ttk.Combobox(f, textvariable=self.ser_parity_var, values=SERIAL_PARITY, state="readonly").grid(row=row, column=1, padx=3)
        row += 1
        ttk.Button(f, text="打开/关闭串口", command=self.serial_toggle).grid(row=row, column=0, columnspan=3, pady=6)
        self.refresh_serial_ports()
        if not SERIAL_AVAIL:
            for w in f.winfo_children():
                w.config(state="disabled")

    def refresh_serial_ports(self):
        if not SERIAL_AVAIL:
            return
        ports = [p.device for p in list_ports.comports()]
        self.cmb_serial_port["values"] = ports
        if ports and self.ser_port_var.get() not in ports:
            self.ser_port_var.set(ports[0])

    def can_toggle(self):
        if not CAN_AVAIL:
            messagebox.showwarning("依赖缺失", "未安装 python-can 库,无法使用CAN通道")
            return
        if self.can_bus is not None:
            self.can_recv_run = False
            if self.can_recv_thread is not None:
                self.can_recv_thread.join(0.2)
            try:
                self.can_bus.shutdown()
                self.log("CAN通道关闭")
            except Exception as e:
                self.log(f"CAN关闭异常:{e}")
            self.can_bus = None
            self.status_var.set("通道:未打开")
            return
        try:
            chan = self.can_chan_var.get()
            arb = int(self.can_arb_baud_var.get())
            fd_baud = int(self.can_fd_var.get())
            try:
                tx_id = int(self.can_txid_var.get(), 16)
            except:
                tx_id = 0x7FF
            interface = self.can_interface_var.get()
            self.cfg["can"]["interface"] = interface
            self.cfg["can"]["tx_id"] = tx_id
            self.cfg["can"]["ext"] = self.can_ext_var.get()
            self.save_cfg()
            is_can_fd = (self.can_mode_var.get() == "CAN-FD")
            if is_can_fd:
                self.can_bus = can.Bus(interface=interface, channel=chan, fd=True, bitrate=arb, data_bitrate=fd_baud)
            else:
                self.can_bus = can.Bus(interface=interface, channel=chan, bitrate=arb)
            self.log(f"CAN通道打开成功 接口={interface} 通道={chan}")
            self.status_var.set("CAN 已打开")
            self.can_recv_run = True
            self.can_recv_thread = threading.Thread(target=self.can_recv_loop, daemon=True)
            self.can_recv_thread.start()
        except Exception as e:
            self.can_bus = None
            self.log(f"CAN打开失败:{e}")

    def build_can_panel(self, parent):
        f = parent
        for w in f.winfo_children():
            w.destroy()
        can_cfg = self.cfg["can"]
        self.can_interface_var.set(str(can_cfg.get("interface", "pcan")))
        self.can_chan_var.set(str(can_cfg["channel"]))
        self.can_arb_baud_var.set(str(can_cfg["arb_baud"]))
        self.can_fd_var.set(str(can_cfg["fd_baud"]))
        self.can_fd_en_var.set(can_cfg["fd_enable"])
        self.can_ext_var.set(can_cfg["ext"])
        self.can_txid_var.set(hex(can_cfg.get("tx_id", 0x7FF))[2:])

        row = 0
        ttk.Label(f, text="CAN模式:").grid(row=row, column=0, sticky="w", padx=3, pady=4)
        cb_can_mode = ttk.Combobox(f, textvariable=self.can_mode_var, values=["CAN2.0", "CAN-FD"], state="readonly")
        cb_can_mode.grid(row=row, column=1, padx=3)
        cb_can_mode.bind("<<ComboboxSelected>>", self.on_can_mode_change)
        row += 1
        ttk.Label(f, text="DLC:").grid(row=row, column=0, sticky="w", padx=3, pady=4)
        self.cb_dlc = ttk.Combobox(f, textvariable=self.can_dlc_var, values=[str(x) for x in self.dlc_list], state="readonly", width=8)
        self.cb_dlc.grid(row=row, column=1, padx=3)
        row += 1
        ttk.Label(f, text="适配器接口:").grid(row=row, column=0, sticky="w", padx=3, pady=4)
        ttk.Combobox(f, textvariable=self.can_interface_var, values=CAN_INTERFACES, state="readonly", width=12).grid(row=row, column=1, padx=3)
        row += 1
        ttk.Label(f, text="CAN适配器通道:").grid(row=row, column=0, sticky="w", padx=3, pady=4)
        ttk.Entry(f, textvariable=self.can_chan_var, width=10).grid(row=row, column=1, padx=3)
        row += 1
        ttk.Label(f, text="发送ID(十六进制):").grid(row=row, column=0, sticky="w", padx=3, pady=4)
        ttk.Entry(f, textvariable=self.can_txid_var, width=10).grid(row=row, column=1, padx=3)
        row += 1
        ttk.Label(f, text="仲裁波特率:").grid(row=row, column=0, sticky="w", padx=3, pady=4)
        ttk.Combobox(f, textvariable=self.can_arb_baud_var, values=[str(x) for x in CAN_ARB_BAUD], state="readonly").grid(row=row, column=1, padx=3)
        row += 1
        ttk.Label(f, text="FD数据波特率:").grid(row=row, column=0, sticky="w", padx=3, pady=4)
        self.cb_fd = ttk.Combobox(f, textvariable=self.can_fd_var, values=[str(x) for x in CAN_FD_BAUD], state="readonly")
        self.cb_fd.grid(row=row, column=1, padx=3)
        self.fd_switch()
        row += 1
        ttk.Checkbutton(f, text="扩展帧ID", variable=self.can_ext_var).grid(row=row, column=0)
        row += 1
        ttk.Button(f, text="打开/关闭CAN通道", command=self.can_toggle).grid(row=row, column=0, columnspan=3, pady=6)
        self.on_can_mode_change()
        if not CAN_AVAIL:
            for w in f.winfo_children():
                w.config(state="disabled")
            ttk.Label(f, text="未安装 python-can 库,CAN通道不可用", foreground="#882200").grid(
                row=row + 1, column=0, columnspan=3, pady=2)

    def fd_switch(self):
        if not hasattr(self, "cb_fd") or self.cb_fd is None:
            return
        if self.can_fd_en_var.get():
            self.cb_fd.config(state="readonly")
        else:
            self.cb_fd.config(state="disabled")

    def build_usb_cdc_panel(self, parent):
        f = parent
        for w in f.winfo_children():
            w.destroy()
        cdc_cfg = self.cfg.setdefault("usb_cdc", {"port": "", "baud": 115200, "data": 8, "stop": 1, "parity": "N"})
        self.cdc_port_var.set(cdc_cfg["port"])
        self.cdc_baud_var.set(str(cdc_cfg["baud"]))
        self.cdc_databit_var.set(str(cdc_cfg["data"]))
        self.cdc_stopbit_var.set(str(cdc_cfg["stop"]))
        self.cdc_parity_var.set(cdc_cfg["parity"])

        row = 0
        ttk.Label(f, text="CDC‑COM端口:").grid(row=row, column=0, sticky="w", padx=3, pady=4)
        self.cmb_cdc_port = ttk.Combobox(f, textvariable=self.cdc_port_var, width=15, state="readonly")
        self.cmb_cdc_port.grid(row=row, column=1, padx=3)
        ttk.Button(f, text="刷新端口", command=self.refresh_cdc_ports).grid(row=row, column=2)
        row += 1
        ttk.Label(f, text="波特率(仅通知MCU):").grid(row=row, column=0, sticky="w", padx=3, pady=4)
        ttk.Combobox(f, textvariable=self.cdc_baud_var, values=[str(x) for x in SERIAL_BAUDS], state="readonly").grid(row=row, column=1, padx=3)
        row += 1
        ttk.Label(f, text="提示:USB‑CDC波特率仅通知单片机,实际速度由USB决定", foreground="#882200").grid(row=row, column=0, columnspan=3, pady=2)
        row += 1
        ttk.Button(f, text="打开/关闭 USB‑CDC", command=self.usb_cdc_toggle).grid(row=row, column=0, columnspan=3, pady=6)
        self.refresh_cdc_ports()
        if not SERIAL_AVAIL:
            for w in f.winfo_children():
                w.config(state="disabled")

    def refresh_cdc_ports(self):
        if not SERIAL_AVAIL:
            return
        ports = [p.device for p in list_ports.comports()]
        self.cmb_cdc_port["values"] = ports
        if ports and self.cdc_port_var.get() not in ports:
            self.cdc_port_var.set(ports[0])

    def usb_cdc_toggle(self):
        if self.cdc_ser and self.cdc_ser.is_open:
            self.cdc_recv_run = False
            if self.cdc_recv_thread is not None:
                self.cdc_recv_thread.join(0.2)
            self.cdc_ser.close()
            self.cdc_ser = None
            self.log("USB‑CDC虚拟串口已关闭")
            self.status_var.set("通道:未打开")
            return
        try:
            port = self.cdc_port_var.get()
            baud = int(self.cdc_baud_var.get())
            databit = int(self.cdc_databit_var.get())
            stopbit = float(self.cdc_stopbit_var.get())
            parity = self.cdc_parity_var.get()
            self.cdc_ser = serial.Serial(
                port,
                baudrate=baud,
                bytesize=databit,
                stopbits=stopbit,
                parity=parity,
                timeout=0,
                write_timeout=0.2,
                rtscts=False,
                dsrdtr=False
            )
            self.cdc_ser.reset_input_buffer()
            self.cdc_ser.reset_output_buffer()
            # 保存配置
            self.cfg["usb_cdc"]["port"] = port
            self.cfg["usb_cdc"]["baud"] = baud
            self.cfg["usb_cdc"]["data"] = databit
            self.cfg["usb_cdc"]["stop"] = stopbit
            self.cfg["usb_cdc"]["parity"] = parity
            self.save_cfg()
            # 启动接收线程
            self.cdc_recv_run = True
            self.cdc_recv_thread = threading.Thread(target=self.usb_cdc_recv_loop, daemon=True)
            self.cdc_recv_thread.start()
            self.log(f"USB‑CDC打开成功 {port} (虚拟串口)")
            self.status_var.set(f"USB‑CDC {port} 已打开")
        except Exception as e:
            self.cdc_ser = None
            self.log(f"USB‑CDC打开失败:{e}")
            messagebox.showerror("USB‑CDC打开失败", str(e))

    def usb_cdc_recv_loop(self):
        buffer = b""
        last_byte_time = time.time()
        IDLE_THRESHOLD = 0.01
        while self.cdc_recv_run and self.cdc_ser and self.cdc_ser.is_open:
            try:
                cnt = self.cdc_ser.in_waiting
                if cnt > 0:
                    data = self.cdc_ser.read(cnt)
                    buffer += data
                    last_byte_time = time.time()
                    if len(buffer) > 4096:
                        buffer = buffer[-2048:]
                else:
                    delta = time.time() - last_byte_time
                    if len(buffer) > 0 and delta >= IDLE_THRESHOLD:
                        hex_str = " ".join(f"{b:02X}" for b in buffer)
                        if self.enable_recv_log.get():
                            self.log(f"USB‑CDC接收: {hex_str}")
                        buffer = b""
                time.sleep(0.001)
            except Exception as e:
                self.log(f"USB‑CDC接收异常: {e}")
                buffer = b""
                time.sleep(0.01)

    def build_usb_panel(self, parent):
        f = parent
        for w in f.winfo_children():
            w.destroy()
        usb_cfg = self.cfg["usb_vid"]
        self.usb_vid_var = tk.StringVar(value=hex(usb_cfg.get("vid", 0))[2:])
        self.usb_pid_var = tk.StringVar(value=hex(usb_cfg.get("pid", 0))[2:])
        self.usb_rlen_var.set(str(usb_cfg.get("report_len", 64)))
        row = 0
        ttk.Label(f, text="VID(十六进制):").grid(row=row, column=0, sticky="w", padx=3, pady=4)
        ttk.Entry(f, textvariable=self.usb_vid_var, width=12).grid(row=row, column=1, padx=3)
        row += 1
        ttk.Label(f, text="PID(十六进制):").grid(row=row, column=0, sticky="w", padx=3, pady=4)
        ttk.Entry(f, textvariable=self.usb_pid_var, width=12).grid(row=row, column=1, padx=3)
        row += 1
        ttk.Label(f, text="报告长度(字节):").grid(row=row, column=0, sticky="w", padx=3, pady=4)
        ttk.Entry(f, textvariable=self.usb_rlen_var, width=12).grid(row=row, column=1, padx=3)
        row += 1
        ttk.Button(f, text="打开/断开USB-HID", command=self.usb_toggle).grid(row=row, column=0, columnspan=2, pady=8)
        if not USB_AVAIL:
            for w in f.winfo_children():
                w.config(state="disabled")
            ttk.Label(f, text="未安装 hidapi 库,USB-HID通道不可用", foreground="#882200").grid(
                row=row + 1, column=0, columnspan=2, pady=2)

    def serial_recv_loop(self):
        buffer = b""
        last_byte_time = time.time()
        # 空闲阈值,单位秒,可调:10ms ~30ms
        IDLE_THRESHOLD = 0.01
        while self.ser_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:
                    # 没有新字节,判断是否空闲超时
                    delta = time.time() - last_byte_time
                    if len(buffer) > 0 and delta >= IDLE_THRESHOLD:
                        # 空闲超时,判定为完整一帧
                        hex_str = " ".join(f"{b:02X}" for b in buffer)
                        if self.enable_recv_log.get():
                            self.log(f"串口接收: {hex_str}")
                        buffer = b""
                time.sleep(0.001)
            except Exception as e:
                self.log(f"串口接收异常: {e}")
                buffer = b""
                time.sleep(0.01)

    def can_recv_loop(self):
        while self.can_bus is not None and self.can_recv_run:
            try:
                msg = self.can_bus.recv(timeout=0.01)
                if msg is not None:
                    hex_str = " ".join(f"{b:02X}" for b in msg.data)
                    if self.enable_recv_log.get():
                        ext_tag = "扩展帧" if msg.is_extended_id else "标准帧"
                        fd_tag = "[CAN-FD]" if msg.is_fd else ""
                        self.log(f"CAN接收 ID=0x{msg.arbitration_id:X} {ext_tag}{fd_tag} DATA:{hex_str}")
            except Exception:
                time.sleep(0.01)

    def usb_recv_loop(self):
        report_len = int(self.usb_rlen_var.get())
        while self.usb_recv_run and self.usb_dev is not None:
            try:
                data = self.usb_dev.read(report_len, timeout_ms=50)
                if data:
                    hex_str = " ".join(f"{b:02X}" for b in data)
                    if self.enable_recv_log.get():
                        self.log(f"USB-HID接收: {hex_str}")
            except Exception:
                time.sleep(0.05)

    def usb_toggle(self):
        if not USB_AVAIL:
            messagebox.showwarning("依赖缺失", "未安装 hidapi 库,无法使用USB-HID通道")
            return
        if self.usb_dev is not None:
            # 关闭设备、停止接收线程
            self.usb_recv_run = False
            if self.usb_recv_thread is not None:
                self.usb_recv_thread.join(0.2)
            try:
                self.usb_dev.close()
                self.log("USB‑HID设备已断开")
            except Exception as e:
                self.log(f"USB‑HID关闭时异常:{e}")
            self.usb_dev = None
            self.status_var.set("通道:未打开")
            return
        try:
            vid = int(self.usb_vid_var.get(), 16)
            pid = int(self.usb_pid_var.get(), 16)
            report_len = int(self.usb_rlen_var.get())
            # 打开HID设备
            self.usb_dev = hid.device()
            self.usb_dev.open(vid, pid)
            # 保存配置
            self.cfg["usb_vid"]["vid"] = vid
            self.cfg["usb_vid"]["pid"] = pid
            self.cfg["usb_vid"]["report_len"] = report_len
            self.save_cfg()
            self.log(f"USB‑HID打开成功 VID:{vid:04X} PID:{pid:04X}")
            self.status_var.set("USB‑HID 已连接")
            # 启动后台接收线程
            self.usb_recv_run = True
            self.usb_recv_thread = threading.Thread(target=self.usb_recv_loop, daemon=True)
            self.usb_recv_thread.start()
        except Exception as e:
            self.usb_dev = None
            self.log(f"USB‑HID打开失败:{str(e)}")
            messagebox.showerror("USB错误", f"设备打开失败:{e}")

    def serial_toggle(self):
        if self.ser and self.ser.is_open:
            # 停止接收线程
            self.ser_recv_run = False
            if self.ser_recv_thread is not None:
                self.ser_recv_thread.join(0.2)
            self.ser.close()
            self.ser = None
            self.log("串口已关闭")
            self.status_var.set("通道:未打开")
            return
        try:
            port = self.ser_port_var.get()
            baud = int(self.ser_baud_var.get())
            databit = int(self.ser_databit_var.get())
            stopbit = float(self.ser_stopbit_var.get())
            parity = self.ser_parity_var.get()
            self.ser = serial.Serial(port, baudrate=baud, bytesize=databit, stopbits=stopbit, parity=parity, timeout=0, write_timeout=0.2)
            self.ser.reset_input_buffer()
            self.ser.reset_output_buffer()
            self.cfg["serial"]["port"] = port
            self.cfg["serial"]["baud"] = baud
            self.cfg["serial"]["data"] = databit
            self.cfg["serial"]["stop"] = stopbit
            self.cfg["serial"]["parity"] = parity
            self.save_cfg()
            # 启动接收线程
            self.ser_recv_run = True
            self.ser_recv_thread = threading.Thread(target=self.serial_recv_loop, daemon=True)
            self.ser_recv_thread.start()
            self.log(f"串口打开成功 {port} {baud}")
            self.status_var.set(f"串口 {port} 已打开")
        except Exception as e:
            self.log(f"串口打开失败:{e}")
            self.ser = None


    def log(self, text):
        t = time.strftime("%Y-%m-%d %H:%M:%S")
        self.log_text.insert(tk.END, f"[{t}] {text}\n")
        self.log_text.see(tk.END)

    def refresh_send_tree(self):
        for i in self.send_tree.get_children():
            self.send_tree.delete(i)
        for idx, item in enumerate(self.send_list):
            if item["period"] <= 0:
                en_text = "关闭"
            else:
                en_text = "开启" if item["enable"] else "关闭"
            self.send_tree.insert("", "end", iid=str(idx), values=(item["name"], item["hex"], item["period"], en_text))

    def add_send_item(self):
        lib = self.get_msg_lib()
        msg_names = [m["msg_name"] for m in lib.msg_list]
        sel = simpledialog.askstring("选择报文", "从报文库选择名称", initialvalue=msg_names[0] if msg_names else "")
        if sel not in msg_names:
            return
        target = None
        for m in lib.msg_list:
            if m["msg_name"] == sel:
                target = m
                break
        per = simpledialog.askinteger("周期ms", "0代表仅手动发送", initialvalue=1000)
        self.send_list.append({
            "name": sel,
            "hex": target["hex_data"],
            "period": per,
            "enable": True
        })
        self.refresh_send_tree()

    def edit_send_item(self):
        sel = self.send_tree.selection()
        if not sel:
            return
        idx = int(sel[0])
        item = self.send_list[idx]
        win = tk.Toplevel(self.parent.winfo_toplevel())
        win.title("修改发送周期")
        win.geometry("400x160")
        win.transient(self.parent)
        win.grab_set()
        win.columnconfigure(1, weight=1)
        ttk.Label(win, text=f"报文:{item['name']}").grid(row=0, column=0, columnspan=2, pady=8)
        ttk.Label(win, text="周期ms:").grid(row=1, column=0, sticky="w", padx=10)
        var_p = tk.StringVar(value=str(item["period"]))
        ttk.Entry(win, textvariable=var_p, width=20).grid(row=1, column=1, padx=10)

        def save():
            try:
                new_p = int(var_p.get())
            except ValueError:
                messagebox.showerror("输入错误", "请输入数字", parent=win)
                return
            item["period"] = new_p
            self.refresh_send_tree()
            win.destroy()

        ttk.Button(win, text="保存", command=save).grid(row=2, column=0, columnspan=2, pady=12)

    def del_send_item(self):
        sel = self.send_tree.selection()
        if not sel:
            return
        idx = int(sel[0])
        del self.send_list[idx]
        self.refresh_send_tree()

    def clear_send_list(self):
        self.send_list.clear()
        self.refresh_send_tree()

    def send_single(self):
        active = self.comm_sel.get()
        sel = self.send_tree.selection()
        if not sel:
            messagebox.showwarning("提示", "请选中一条报文")
            return
        idx = int(sel[0])
        hex_str = self.send_list[idx]["hex"].strip()
        try:
            raw = binascii.unhexlify(hex_str.replace(" ", ""))
        except:
            self.log("报文HEX格式错误")
            messagebox.showerror("发送失败", "报文十六进制格式错误")
            return

        if active == "串口(TTL/RS232)":
            if not (self.ser and self.ser.is_open):
                msg = "串口通道未打开,请先配置并打开串口"
                self.log(msg)
                messagebox.showwarning("通道未就绪", msg)
                return
            try:
                self.ser.write(raw)
                self.ser.flush()
                self.ser.reset_output_buffer()
                self.log(f"串口发送: {raw.hex(' ')}")
            except Exception as send_err:
                self.log(f"串口发送失败:{send_err}")
                messagebox.showerror("发送错误", f"报文发送失败:{send_err}")
        elif active == "CAN":
            if self.can_bus is None:
                msg = "CAN通道未打开,请先配置并打开CAN"
                self.log(msg)
                messagebox.showwarning("通道未就绪", msg)
                return
            try:
                tx_id = int(self.can_txid_var.get(), 16)
            except:
                tx_id = 0x7FF
            dlc = int(self.can_dlc_var.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_var.get(),
                              is_fd=(self.can_mode_var.get() == "CAN-FD"))
            self.can_bus.send(msg)
            self.log(f"CAN发送 ID=0x{tx_id:X} DATA:{bytes(data).hex(' ')}")
        elif active == "USB‑CDC":
            if not (self.cdc_ser and self.cdc_ser.is_open):
                msg = "USB‑CDC未打开"
                self.log(msg)
                messagebox.showwarning("通道未就绪", msg)
                return
            try:
                self.cdc_ser.write(raw)
                self.cdc_ser.flush()
                self.log(f"USB‑CDC发送: {raw.hex(' ')}")
            except Exception as send_err:
                self.log(f"USB‑CDC发送失败:{send_err}")
                messagebox.showerror("发送错误", f"报文发送失败:{send_err}")
        elif active == "USB-HID":
            if self.usb_dev is None:
                msg = "USB-HID设备未打开"
                self.log(msg)
                messagebox.showwarning("通道未就绪", msg)
                return
            try:
                report_len = int(self.usb_rlen_var.get())
                send_buf = bytearray(report_len)
                send_buf[0] = 0
                copy_len = min(len(raw), report_len - 1)
                send_buf[1:1 + copy_len] = raw[:copy_len]
                n = self.usb_dev.write(bytes(send_buf))
                if n != len(send_buf):
                    raise IOError(f"仅写入{n}/{len(send_buf)}字节")
                self.log(f"USB-HID发送: {send_buf.hex(' ')}")
            except Exception as e:
                self.log(f"USB发送失败:{e}")
                messagebox.showerror("发送异常", str(e))
        else:
            msg = "未识别的通信通道"
            self.log(msg)
            messagebox.showerror("错误", msg)

    def toggle_thread(self):
        self.thread_run = self.var_global_auto.get()
        if self.thread_run:
            self.send_thread = threading.Thread(target=self.send_loop, daemon=True)
            self.send_thread.start()
        else:
            self.send_thread = None

    def send_loop(self):
        last_send = {}
        while self.thread_run:
            now = time.time() * 1000
            for item in self.send_list:
                if not item["enable"] or item["period"] <= 0:
                    continue
                name = item["name"]
                if now - last_send.get(name, 0) >= item["period"]:
                    try:
                        raw = binascii.unhexlify(item["hex"].replace(" ", ""))
                        self.trans_raw(raw)
                        last_send[name] = now
                    except:
                        pass
            time.sleep(0.005)

    def trans_raw(self, raw):
        active = self.comm_sel.get()
        if active == "串口(TTL/RS232)":
            if self.ser and self.ser.is_open:
                try:
                    self.ser.write(raw)
                    self.ser.flush()
                    self.ser.reset_output_buffer()
                    self.log(f"周期串口发送 {raw.hex(' ')}")
                except Exception as e:
                    self.log(f"周期发送异常:{e}")
            else:
                self.log("周期发送:串口未打开,跳过")
        elif active == "CAN" and self.can_bus:
            try:
                tx_id = int(self.can_txid_var.get(), 16)
            except ValueError:
                tx_id = 0x7FF
            dlc = int(self.can_dlc_var.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_var.get(),
                              is_fd=(self.can_mode_var.get() == "CAN-FD"))
            self.can_bus.send(msg)
            self.log(f"周期CAN发送 ID=0x{tx_id:X} {bytes(data).hex()}")
        elif active == "USB‑CDC":
            if self.cdc_ser and self.cdc_ser.is_open:
                try:
                    self.cdc_ser.write(raw)
                    self.cdc_ser.flush()
                    self.log(f"周期USB‑CDC发送 {raw.hex(' ')}")
                except Exception as e:
                    self.log(f"USB‑CDC周期发送异常:{e}")
            else:
                self.log("周期发送:USB‑CDC未打开,跳过")
        elif active == "USB-HID":
            if self.usb_dev is None:
                self.log("周期发送:USB未连接,跳过")
                return
            try:
                report_len = int(self.usb_rlen_var.get())
                send_buf = bytearray(report_len)
                send_buf[0] = 0
                copy_len = min(len(raw), report_len - 1)
                send_buf[1:1 + copy_len] = raw[:copy_len]
                n = self.usb_dev.write(bytes(send_buf))
                if n != len(send_buf):
                    raise IOError(f"仅写入{n}/{len(send_buf)}字节")
                self.log(f"周期USB发送 {send_buf.hex()}")
            except Exception as e:
                self.log(f"USB周期发送异常:{e}")

# ===================== 主窗口多Tab入口 =====================

class MainApp:
    def __init__(self, root):
        self.root = root
        self.root.title("综合协议工具")
        self.root.geometry("1260x840")
        notebook = ttk.Notebook(root)
        notebook.pack(fill="both", expand=True, padx=6, pady=6)
        # Tab0 协议编辑器
        frame0 = ttk.Frame(notebook)
        notebook.add(frame0, text="协议模板编辑器")
        self.editor = ProtocolEditor(frame0, self.on_template_changed)
        # Tab1 报文管理
        frame1 = ttk.Frame(notebook)
        notebook.add(frame1, text="常用报文库")
        self.msg_tab = MessageLibTab(frame1, self.on_template_changed, lambda i: notebook.select(), self.editor)
        # Tab2 通信发送
        frame2 = ttk.Frame(notebook)
        notebook.add(frame2, text="总线发送控制台")
        self.comm_tab = CommSendTab(frame2, lambda: self.msg_tab)

        def on_tab_switch(event):
            idx = notebook.index(notebook.select())
            if idx == 1:
                self.refresh_tmpl_cmb()
            elif idx == 2:
                self.comm_tab.refresh_cat_cmb()
        notebook.bind("<<NotebookTabChanged>>", on_tab_switch)

    def on_template_changed(self):
        self.msg_tab.refresh_template_cmb(self.editor)

    def refresh_tmpl_cmb(self):
        self.msg_tab.refresh_template_cmb(self.editor)

    def close_event(self):
        # 停止发送循环
        self.comm_tab.thread_run = False
        if self.comm_tab.send_thread is not None:
            self.comm_tab.send_thread.join(0.3)  # 等待线程退出

        if self.comm_tab.ser and self.comm_tab.ser.is_open:
            self.comm_tab.ser_recv_run = False
            if self.comm_tab.ser_recv_thread:
                self.comm_tab.ser_recv_thread.join(0.2)
            self.comm_tab.ser.close()
            self.comm_tab.ser = None
        if self.comm_tab.cdc_ser and self.comm_tab.cdc_ser.is_open:
            self.comm_tab.cdc_recv_run = False
            if self.comm_tab.cdc_recv_thread:
                self.comm_tab.cdc_recv_thread.join(0.2)
            self.comm_tab.cdc_ser.close()
            self.comm_tab.cdc_ser = None
        if self.comm_tab.can_bus is not None:
            self.comm_tab.can_recv_run = False
            if self.comm_tab.can_recv_thread:
                self.comm_tab.can_recv_thread.join(0.2)
            self.comm_tab.can_bus.shutdown()
            self.comm_tab.can_bus = None
        if self.comm_tab.usb_dev is not None:
            self.comm_tab.usb_recv_run = False
            if self.comm_tab.usb_recv_thread:
                self.comm_tab.usb_recv_thread.join(0.2)
            self.comm_tab.usb_dev.close()
            self.comm_tab.usb_dev = None
        self.root.destroy()

if __name__ == "__main__":
    try:
        root = tk.Tk()
        app = MainApp(root)
        root.protocol("WM_DELETE_WINDOW", app.close_event)
        root.mainloop()
    except Exception as err:
        import tkinter.messagebox
        tkinter.messagebox.showerror("启动异常", str(err))

3、可执行程序生成及注意事项

(1)在程序源码文件protocol_sender.py所在目录下打开终端,输入命令“pyinstaller -w -F protocol_sender.py”,即可生成可执行文件protocol_sender.exe。

(2)不管是运行protocol_sender.py还是protocol_sender.exe,其所在目录下如果没有小程序需要读写的JSON文件,都会直接新建,切勿随意修改它们。

(3)在实际开发中如果有其它通信接口需求,可在该小程序的基础上继续增加功能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Zevalin爱灰灰

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值