Workbuddy自动写一个RS485 / LoRa 参数调试工具

前言

在做 RS485 / LoRa 无线模块开发时,经常需要读写模块的内部参数:固件版本、SN 序列号、网络标识、LoRa 的信道/扩频因子/带宽/纠错码率/发射功率,以及串口波特率、停止位、校验位等。手边没有趁手的调试软件,每次都要拿串口助手拼 HEX 帧、自己算 CRC,既容易算错,又效率极低。

于是用Workbuddy写了一个图形化调试工具,双击即用,支持:

  • 串口参数配置与一键连接
  • 固件信息、SN、网络标识、LoRa 参数、串口参数的读取与写入
  • 原始 HEX 报文发送(自动算 CRC)
  • 实时通信日志,可保存为 txt
  • 界面 + 日志一键中英文切换
  • 用 PyInstaller 打包成单个 .exe

本文从协议分析 → 功能设计 → 分步实现 → 完整代码 → 打包发布,完整记录整个开发过程,代码工程可直接复制使用。


一、协议分析(帧格式)

该模块采用一套自定义的私有协议,但结构上和 Modbus 类似,掌握规律后非常好解析。

发送帧格式:

复制代码
FE BB 55 25 | 功能码 | 命令高 | 命令低 | [数据] | CRC16低 | CRC16高
  • 帧头:FE BB 55 25(固定 4 字节)
  • 功能码:读 0x03、写 0x10
  • 命令字:2 字节,标识要读写的参数项
  • 数据段:可选,长度由命令决定
  • CRC16:Modbus 算法(多项式 0x8005,初始值 0xFFFF),低字节在前

命令字清单:

命令 含义 数据长度
0x0000 固件信息(只读) 4 字节:SW(2B) + Ver(2B)
0x0001 SN 序列号 14 字节 ASCII
0x0002 网络标识 14 字节,写全 0 可解绑
0x0003 LoRa 参数 5 字节:CH/SF/BW/CR/Power
0x0004 串口参数 6 字节:波特率(4B 大端)+停止位+校验位

写一个 LoRa 参数的例子:

把信道设为 3、SF=7、BW=125K、CR=4/5、功率 20dBm,写帧为:

复制代码
FE BB 55 25 10 00 03 03 07 01 01 14 [CRC16]

工具会自动帮你拼好数据段并算 CRC,不用手算。


二、功能设计

模块 功能
串口设置 端口、波特率、数据位、校验、停止位、超时、语言选择,一键打开/关闭
参数配置 固件信息、SN、网络标识、LoRa 参数、串口参数 的读取/写入
原始报文 手动输入 HEX,自动识别并补算 CRC;一键生成"读固件""读 LoRa"报文
通信日志 实时打印收发报文与解析结果,可清空、可保存
国际化 中英文一键切换,覆盖标题/按钮/标签/日志

界面用 ttk.Notebook 分成两个标签页:参数配置原始报文,底部是通信日志。


三、开发环境

项目 说明
系统 Windows 10/11(打包 exe);Linux 亦可运行源码
Python 3.8+(推荐 3.10+)
GUI Tkinter(Python 自带,无需额外装)
串口 pyserial
打包 PyInstaller

安装依赖:

复制代码
pip install pyserial pyinstaller

Tkinter 在标准 Python 安装里自带。如果是 Linux 最小化环境,用 sudo apt install python3-tk 补装即可。


四、分步实现(开发步骤详解)

下面按实际开发顺序讲解每一步的核心思路与关键代码。

步骤 1:搭界面骨架(Tkinter)

先不关心协议细节,把窗口和布局搭起来:顶部串口设置、中间 Notebook 标签页、底部日志区。用 ttk.LabelFrame 分组,布局用 pack,组内用 grid,清晰又好维护。

复制代码
import tkinter as tk
from tkinter import ttk, messagebox, scrolledtext, filedialog

class DebuggerApp:
    def __init__(self, root):
        self.root = root
        self.root.geometry("1000x820")
        self.build_ui()

步骤 2:实现 CRC16 与协议封装

这是工具的"内核"。Modbus CRC16 算法很标准,注意要返回 int,再拆成低字节、高字节拼到帧尾。

复制代码
def crc16_modbus(data: bytes) -> int:
    crc = 0xFFFF
    for byte in data:
        crc ^= byte
        for _ in range(8):
            if crc & 0x0001:
                crc = (crc >> 1) ^ 0xA001
            else:
                crc >>= 1
    return crc

HEAD = bytes([0xFE, 0xBB, 0x55, 0x25])

def build_frame(func, cmd, data=b""):
    body = HEAD + bytes([func, (cmd >> 8) & 0xFF, cmd & 0xFF]) + data
    crc = crc16_modbus(body)
    return body + bytes([crc & 0xFF, (crc >> 8) & 0xFF])   # 低字节在前

def parse_frame(frame):
    if len(frame) < 8 or frame[:4] != HEAD:
        return None
    func = frame[4]
    cmd = (frame[5] << 8) | frame[6]
    payload = frame[7:-2]
    recv_crc = (frame[-1] << 8) | frame[-2]
    ok = crc16_modbus(frame[:-2]) == recv_crc
    return {"func": func, "cmd": cmd, "data": payload, "crc_ok": ok}

步骤 3:串口收发

pyserial 打开串口,发送后用循环读取返回,直到超时或读满一帧。加锁防止多线程同时操作串口。

复制代码
import serial, time, threading

app_state = AppState()  # 持有 ser、lock、timeout

def send_and_receive(ser, frame, timeout_ms=500):
    with app_state.lock:
        ser.reset_input_buffer(); ser.reset_output_buffer()
        ser.write(frame); ser.flush()
        deadline = time.time() + timeout_ms / 1000.0
        buf = b""
        while time.time() < deadline:
            if ser.in_waiting:
                buf += ser.read(ser.in_waiting); time.sleep(0.02)
            elif buf:
                break
            else:
                time.sleep(0.01)
        return buf

步骤 4:参数读写界面

每个参数项(固件、SN、网络、LoRa、串口)对应一组 build_frame + tx_rx + parse_frame。读取时把解析结果填回控件,写入时从控件取值拼数据段。例如 LoRa 读取:

复制代码
def read_lora(self):
    if not self.ensure_open(): return
    resp = self.tx_rx(build_frame(FUNC_READ, CMD_LORA))
    p = parse_frame(resp) if resp else None
    if p and p["crc_ok"] and p["cmd"] == CMD_LORA and len(p["data"]) >= 5:
        ch, sf, bw, cr, pwr = p["data"][:5]
        self.spn_lora_ch.set(str(ch))
        self.cmb_lora_sf.set(str(sf))
        self.cmb_lora_bw.set({1:"1=125K",2:"2=250K",3:"3=500K"}.get(bw, str(bw)))
        self.cmb_lora_cr.set({1:"1=4/5",2:"2=4/6",3:"3=4/7",4:"4=4/8"}.get(cr, str(cr)))
        self.spn_lora_pwr.set(str(pwr))

步骤 5:原始报文 + 日志

"原始报文"页让高级用户手动拼帧。输入 HEX(空格或逗号分隔),结尾 CRC 留 00 00 也会被识别并提示自动计算。日志用带时间戳的 ScrolledText,并支持保存为 txt。

步骤 6:中英文一键切换(重点)

关键点:所有界面文本不要写死 ,统一放到 TRANSLATIONS 字典,由 self.lang 控制当前语言;切换时遍历所有已登记控件刷新文本。日志也通过 self.t(key) 取词,保证切英文后新日志全是英文。

复制代码
TRANSLATIONS = {
    "open":   {"zh": "打开串口", "en": "Open Port"},
    "close":  {"zh": "关闭串口", "en": "Close Port"},
    "tx_send":{"zh": "发送 ->", "en": "TX ->"},
    # ... 共 60+ 条
}

def t(self, key): return TRANSLATIONS[key][self.lang]

def change_language(self, event=None):
    self.lang = "en" if self.cmb_lang.get() == "English" else "zh"
    self.root.title(self.t("title"))
    for lbl, k in self.static_labels:    # 刷新静态标签
        lbl.config(text=self.t(k))
    for key, (w, k) in self.tr.items():  # 刷新按钮/框架标题
        w.config(text=self.t("close" if key=="open_btn" and ser_open else k))
    self.notebook.tab(0, text=self.t("tab_param"))
    self.notebook.tab(1, text=self.t("tab_raw"))
    self.log_tr("lang_switched")

步骤 7:打包成 exe

Windows 上用 PyInstaller 一条命令出单文件 exe:

复制代码
pyinstaller --onefile --windowed --name lora_modbus_tool rs485_lora_debugger.py

--windowed 让程序运行时不弹黑框。打包后 exe 在 dist\ 目录。


五、完整代码工程

5.1 主程序:rs485_lora_debugger.py

直接新建文件 rs485_lora_debugger.py,把下面内容整段复制保存即可运行。

复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
RS485 / LoRa 参数调试工具(支持中英文界面切换)
帧格式:FE BB 55 25 <功能码> <命令高> <命令低> [数据] <CRC16低> <CRC16高>
读(0x03)响应带数据段;写(0x10)响应只回显命令字。写入立即生效,无重启命令。
"""

import sys
import tkinter as tk
from tkinter import ttk, messagebox, scrolledtext, filedialog
import serial
import serial.tools.list_ports
import struct
import threading
import time


# ========== CRC16 计算 ==========
def crc16_modbus(data: bytes) -> int:
    """Modbus CRC16:多项式 0x8005,初始值 0xFFFF"""
    crc = 0xFFFF
    for byte in data:
        crc ^= byte
        for _ in range(8):
            if crc & 0x0001:
                crc = (crc >> 1) ^ 0xA001
            else:
                crc >>= 1
    return crc


# ========== 国际化文本 ==========
TRANSLATIONS = {
    "title":          {"zh": "RS485 / LoRa 参数调试工具", "en": "RS485 / LoRa Parameter Debugger"},
    # 标签页
    "tab_param":      {"zh": "参数配置", "en": "Parameters"},
    "tab_raw":        {"zh": "原始报文", "en": "Raw Frame"},
    # 串口设置
    "serial_settings":{"zh": "串口设置", "en": "Serial Settings"},
    "port":           {"zh": "串口:", "en": "Port:"},
    "refresh":        {"zh": "刷新", "en": "Refresh"},
    "baud":           {"zh": "波特率:", "en": "Baud:"},
    "databits":       {"zh": "数据位:", "en": "Data bits:"},
    "parity":         {"zh": "校验:", "en": "Parity:"},
    "stopbits":       {"zh": "停止位:", "en": "Stop bits:"},
    "open":           {"zh": "打开串口", "en": "Open Port"},
    "close":          {"zh": "关闭串口", "en": "Close Port"},
    "timeout":        {"zh": "读写超时(ms):", "en": "Timeout(ms):"},
    "language":       {"zh": "语言:", "en": "Language:"},
    # 固件 / SN / 网络
    "fw_title":       {"zh": "0x0000 固件信息(只读)", "en": "0x0000 Firmware Info (read-only)"},
    "unread":         {"zh": "未读取", "en": "Not read"},
    "sn_title":       {"zh": "0x0001 SN (14字节)", "en": "0x0001 SN (14 bytes)"},
    "net_title":      {"zh": "0x0002 网络标识 (14字节,写全0=解绑)", "en": "0x0002 Network ID (14B, all-0=unbind)"},
    "read":           {"zh": "读取", "en": "Read"},
    "write":          {"zh": "写入", "en": "Write"},
    # LoRa
    "lora_title":     {"zh": "0x0003 LoRa 参数", "en": "0x0003 LoRa Params"},
    "ch_label":       {"zh": "Channel:", "en": "Channel:"},
    "sf_label":       {"zh": "SF:", "en": "SF:"},
    "bw_label":       {"zh": "BW:", "en": "BW:"},
    "cr_label":       {"zh": "CR:", "en": "CR:"},
    "pwr_label":      {"zh": "Power(dBm):", "en": "Power(dBm):"},
    "standby_hint":   {"zh": "(0=待机)", "en": "(0=standby)"},
    # 串口参数
    "uart_title":     {"zh": "0x0004 串口参数", "en": "0x0004 UART Params"},
    "uart_baud":      {"zh": "波特率:", "en": "Baud:"},
    "uart_stop":      {"zh": "停止位:", "en": "Stop bits:"},
    "uart_parity":    {"zh": "校验位:", "en": "Parity:"},
    # 日志
    "log_title":      {"zh": "通信日志", "en": "Communication Log"},
    "clear_log":      {"zh": "清空日志", "en": "Clear Log"},
    "save_log":       {"zh": "保存日志", "en": "Save Log"},
    # 原始报文
    "raw_hint":       {"zh": "手动输入 HEX 报文(空格分隔,CRC 可留空或自动计算):", "en": "Input HEX frame (space separated, CRC auto-filled if blank):"},
    "send":           {"zh": "发送", "en": "Send"},
    "gen_fw":         {"zh": "生成读取固件报文", "en": "Gen Read-Firmware Frame"},
    "gen_lora":       {"zh": "生成读取LoRa报文", "en": "Gen Read-LoRa Frame"},
    # 动态消息
    "started":        {"zh": "调试工具已启动。请选择串口并点击"打开串口"。", "en": "Debugger started. Select a port and click Open Port."},
    "lang_switched":  {"zh": "界面语言已切换为: 中文", "en": "UI language switched to: English"},
    "port_opened":    {"zh": "串口已打开: ", "en": "Port opened: "},
    "port_closed":    {"zh": "串口已关闭", "en": "Port closed"},
    "port_not_sel":   {"zh": "请先选择串口", "en": "Please select a port first"},
    "open_fail":      {"zh": "打开串口失败: ", "en": "Failed to open port: "},
    "close_fail":     {"zh": "关闭串口失败: ", "en": "Failed to close port: "},
    "serial_req":     {"zh": "请先打开串口", "en": "Please open the port first"},
    "hex_invalid":    {"zh": "HEX 格式不正确", "en": "Invalid HEX format"},
    "frame_short":    {"zh": "报文过短,至少需要 8 字节", "en": "Frame too short, need at least 8 bytes"},
    "not_header":     {"zh": "报文头不是 FE BB 55 25", "en": "Frame header is not FE BB 55 25"},
    "recv_timeout":   {"zh": "接收: 超时无响应", "en": "Receive: timeout, no response"},
    "crc_auto_title": {"zh": "CRC 自动填充", "en": "CRC Auto-fill"},
    "crc_auto_q":     {"zh": "检测到 CRC 为 00 00,是否自动计算并覆盖?", "en": "CRC is 00 00, auto-calculate and overwrite?"},
    "lora_invalid":   {"zh": "LoRa 参数格式不正确", "en": "Invalid LoRa parameters"},
    "uart_invalid":   {"zh": "串口参数格式不正确", "en": "Invalid UART parameters"},
    "log_empty":      {"zh": "日志为空,无需保存", "en": "Log is empty, nothing to save"},
    "log_saved":      {"zh": "日志已保存到: ", "en": "Log saved to: "},
    "log_save_fail":  {"zh": "保存失败: ", "en": "Save failed: "},
    "not_protocol":   {"zh": "解析: 无法识别为协议帧", "en": "Parse: not a recognized protocol frame"},
    # 日志动态前缀
    "tx_send":        {"zh": "发送 ->", "en": "TX ->"},
    "tx_recv":        {"zh": "接收 <-", "en": "RX <-"},
    "parse":          {"zh": "解析:", "en": "Parse:"},
    "crc_ok":         {"zh": "CRC OK", "en": "CRC OK"},
    "crc_fail":       {"zh": "CRC FAIL", "en": "CRC FAIL"},
    "fw_result":      {"zh": "固件信息:", "en": "Firmware:"},
    "fw_short":       {"zh": "固件数据过短", "en": "Firmware data too short"},
}

# 串口参数枚举(随语言变化)
UART_STOP_OPTS = {
    "zh": ["0=1位", "1=1.5位", "2=2位"],
    "en": ["0=1bit", "1=1.5bit", "2=2bit"],
}
UART_PARITY_OPTS = {
    "zh": ["0=无", "1=奇", "2=偶"],
    "en": ["0=none", "1=odd", "2=even"],
}


# ========== 协议封装 ==========
HEAD = bytes([0xFE, 0xBB, 0x55, 0x25])

CMD_FIRMWARE = 0x0000
CMD_SN = 0x0001
CMD_NET_ID = 0x0002
CMD_LORA = 0x0003
CMD_UART = 0x0004

FUNC_READ = 0x03
FUNC_WRITE = 0x10


def build_frame(func: int, cmd: int, data: bytes = b"") -> bytes:
    body = HEAD + bytes([func, (cmd >> 8) & 0xFF, cmd & 0xFF]) + data
    crc = crc16_modbus(body)
    return body + bytes([crc & 0xFF, (crc >> 8) & 0xFF])


def parse_frame(frame: bytes):
    if len(frame) < 8:
        return None
    if frame[:4] != HEAD:
        return None
    func = frame[4]
    cmd = (frame[5] << 8) | frame[6]
    payload = frame[7:-2]
    received_crc = (frame[-1] << 8) | frame[-2]
    crc_ok = crc16_modbus(frame[:-2]) == received_crc
    return {"func": func, "cmd": cmd, "data": payload, "crc_ok": crc_ok, "raw": frame}


# ========== 全局状态 ==========
class AppState:
    def __init__(self):
        self.ser = None
        self.lock = threading.Lock()
        self.read_timeout_ms = 500


app_state = AppState()


# ========== 串口操作 ==========
def list_serial_ports():
    return [p.device for p in serial.tools.list_ports.comports()]


def open_port(port: str, baud: int, bytesize=8, parity="N", stopbits=1, timeout=0.5):
    return serial.Serial(port=port, baudrate=baud, bytesize=bytesize,
                          parity=parity, stopbits=stopbits, timeout=timeout)


def send_and_receive(ser: serial.Serial, frame: bytes, timeout_ms: int = 500) -> bytes:
    with app_state.lock:
        ser.reset_input_buffer()
        ser.reset_output_buffer()
        ser.write(frame)
        ser.flush()
        deadline = time.time() + timeout_ms / 1000.0
        buffer = b""
        while time.time() < deadline:
            if ser.in_waiting:
                chunk = ser.read(ser.in_waiting)
                buffer += chunk
                time.sleep(0.02)
            else:
                if buffer:
                    break
                time.sleep(0.01)
        return buffer


# ========== Tkinter 应用 ==========
class DebuggerApp:
    def __init__(self, root: tk.Tk):
        self.root = root
        self.lang = "zh"
        self.tr = {}  # 动态文本控件引用 {key: (widget, translation_key)}
        self.static_labels = []  # 静态标签引用 [(label, translation_key)]
        self.fw_read = False
        self.root.title(self.t("title"))
        self.root.geometry("1000x820")
        self.root.minsize(900, 700)

        self.build_ui()
        self.refresh_ports()
        self.log_tr("started")

    def t(self, key: str) -> str:
        return TRANSLATIONS[key][self.lang]

    def label(self, parent, text_key: str, **kwargs):
        """创建 Label 并加入静态标签列表,用于语言切换"""
        lbl = ttk.Label(parent, text=self.t(text_key), **kwargs)
        self.static_labels.append((lbl, text_key))
        return lbl

    # ----- UI 构建 -----
    def build_ui(self):
        top = ttk.LabelFrame(self.root, text=self.t("serial_settings"), padding=10)
        top.pack(fill=tk.X, padx=10, pady=5)
        self.tr["serial_settings"] = (top, "serial_settings")

        self.label(top, "port").grid(row=0, column=0, sticky=tk.W)
        self.cmb_port = ttk.Combobox(top, width=14, state="readonly")
        self.cmb_port.grid(row=0, column=1, padx=5)

        self.btn_refresh = ttk.Button(top, text=self.t("refresh"), command=self.refresh_ports)
        self.btn_refresh.grid(row=0, column=2, padx=5)
        self.tr["refresh"] = (self.btn_refresh, "refresh")

        self.label(top, "baud").grid(row=0, column=3, sticky=tk.W, padx=(15, 0))
        self.cmb_baud = ttk.Combobox(top, values=["1200", "2400", "4800", "9600", "19200", "38400", "57600", "115200"], width=10)
        self.cmb_baud.set("9600")
        self.cmb_baud.grid(row=0, column=4, padx=5)

        self.label(top, "databits").grid(row=0, column=5, sticky=tk.W, padx=(15, 0))
        self.cmb_databits = ttk.Combobox(top, values=["8", "7", "6", "5"], width=6)
        self.cmb_databits.set("8")
        self.cmb_databits.grid(row=0, column=6, padx=5)

        self.label(top, "parity").grid(row=0, column=7, sticky=tk.W, padx=(15, 0))
        self.cmb_parity = ttk.Combobox(top, values=["N", "E", "O", "M", "S"], width=6)
        self.cmb_parity.set("N")
        self.cmb_parity.grid(row=0, column=8, padx=5)

        self.label(top, "stopbits").grid(row=0, column=9, sticky=tk.W, padx=(15, 0))
        self.cmb_stopbits = ttk.Combobox(top, values=["1", "1.5", "2"], width=6)
        self.cmb_stopbits.set("1")
        self.cmb_stopbits.grid(row=0, column=10, padx=5)

        self.btn_open = ttk.Button(top, text=self.t("open"), command=self.toggle_port)
        self.btn_open.grid(row=0, column=11, padx=(15, 5))
        self.tr["open_btn"] = self.btn_open

        self.label(top, "timeout").grid(row=1, column=0, sticky=tk.W, pady=(10, 0))
        self.spn_timeout = ttk.Spinbox(top, from_=100, to=5000, increment=100, width=8)
        self.spn_timeout.set("500")
        self.spn_timeout.grid(row=1, column=1, padx=5, pady=(10, 0))

        # 语言切换
        self.label(top, "language").grid(row=1, column=2, sticky=tk.W, padx=(15, 0), pady=(10, 0))
        self.cmb_lang = ttk.Combobox(top, values=["中文", "English"], width=10, state="readonly")
        self.cmb_lang.set("中文")
        self.cmb_lang.grid(row=1, column=3, padx=5, pady=(10, 0))
        self.cmb_lang.bind("<<ComboboxSelected>>", self.change_language)

        # 笔记本标签页
        self.notebook = ttk.Notebook(self.root)
        self.notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
        param_tab = ttk.Frame(self.notebook, padding=10)
        self.notebook.add(param_tab, text=self.t("tab_param"))
        self.build_param_tab(param_tab)
        raw_tab = ttk.Frame(self.notebook, padding=10)
        self.notebook.add(raw_tab, text=self.t("tab_raw"))
        self.build_raw_tab(raw_tab)

        # 底部日志
        log_frame = ttk.LabelFrame(self.root, text=self.t("log_title"), padding=5)
        log_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
        self.tr["log_title"] = (log_frame, "log_title")

        self.txt_log = scrolledtext.ScrolledText(log_frame, wrap=tk.WORD, height=16, font=("Consolas", 10))
        self.txt_log.pack(fill=tk.BOTH, expand=True)

        btn_frame_log = ttk.Frame(log_frame)
        btn_frame_log.pack(fill=tk.X, pady=5)
        self.btn_save_log = ttk.Button(btn_frame_log, text=self.t("save_log"), command=self.save_log)
        self.btn_save_log.pack(side=tk.RIGHT, padx=2)
        self.btn_clear_log = ttk.Button(btn_frame_log, text=self.t("clear_log"), command=self.clear_log)
        self.btn_clear_log.pack(side=tk.RIGHT, padx=2)
        self.tr["save_log"] = (self.btn_save_log, "save_log")
        self.tr["clear_log"] = (self.btn_clear_log, "clear_log")

    def build_param_tab(self, parent):
        # 固件信息
        frm_fw = ttk.LabelFrame(parent, text=self.t("fw_title"), padding=8)
        frm_fw.pack(fill=tk.X, pady=5)
        self.tr["fw_title"] = (frm_fw, "fw_title")
        self.lbl_fw = ttk.Label(frm_fw, text=self.t("unread"), font=("Consolas", 10))
        self.lbl_fw.pack(side=tk.LEFT, padx=5)
        self.btn_fw_read = ttk.Button(frm_fw, text=self.t("read"), command=self.read_firmware)
        self.btn_fw_read.pack(side=tk.RIGHT, padx=5)
        self.tr["read_fw"] = (self.btn_fw_read, "read")

        # SN
        frm_sn = ttk.LabelFrame(parent, text=self.t("sn_title"), padding=8)
        frm_sn.pack(fill=tk.X, pady=5)
        self.tr["sn_title"] = (frm_sn, "sn_title")
        self.entry_sn = ttk.Entry(frm_sn, font=("Consolas", 10))
        self.entry_sn.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5)
        self.btn_sn_read = ttk.Button(frm_sn, text=self.t("read"), command=self.read_sn)
        self.btn_sn_read.pack(side=tk.RIGHT, padx=2)
        self.btn_sn_write = ttk.Button(frm_sn, text=self.t("write"), command=self.write_sn)
        self.btn_sn_write.pack(side=tk.RIGHT, padx=2)
        self.tr["read_sn"] = (self.btn_sn_read, "read")
        self.tr["write_sn"] = (self.btn_sn_write, "write")

        # 网络标识
        frm_net = ttk.LabelFrame(parent, text=self.t("net_title"), padding=8)
        frm_net.pack(fill=tk.X, pady=5)
        self.tr["net_title"] = (frm_net, "net_title")
        self.entry_net = ttk.Entry(frm_net, font=("Consolas", 10))
        self.entry_net.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5)
        self.btn_net_read = ttk.Button(frm_net, text=self.t("read"), command=self.read_net_id)
        self.btn_net_read.pack(side=tk.RIGHT, padx=2)
        self.btn_net_write = ttk.Button(frm_net, text=self.t("write"), command=self.write_net_id)
        self.btn_net_write.pack(side=tk.RIGHT, padx=2)
        self.tr["read_net"] = (self.btn_net_read, "read")
        self.tr["write_net"] = (self.btn_net_write, "write")

        # LoRa 参数(横向排列)
        frm_lora = ttk.LabelFrame(parent, text=self.t("lora_title"), padding=8)
        frm_lora.pack(fill=tk.X, pady=5)
        self.tr["lora_title"] = (frm_lora, "lora_title")

        row_params = ttk.Frame(frm_lora)
        row_params.pack(fill=tk.X, pady=2)
        self.label(row_params, "ch_label").pack(side=tk.LEFT, padx=(0, 2))
        self.spn_lora_ch = ttk.Spinbox(row_params, from_=0, to=62, width=6)
        self.spn_lora_ch.set("3")
        self.spn_lora_ch.pack(side=tk.LEFT, padx=2)
        self.label(row_params, "standby_hint").pack(side=tk.LEFT, padx=(0, 15))

        self.label(row_params, "sf_label").pack(side=tk.LEFT, padx=(0, 2))
        self.cmb_lora_sf = ttk.Combobox(row_params, values=["7", "8", "9", "10", "11", "12"], width=5, state="readonly")
        self.cmb_lora_sf.set("7")
        self.cmb_lora_sf.pack(side=tk.LEFT, padx=2)

        self.label(row_params, "bw_label").pack(side=tk.LEFT, padx=(10, 2))
        self.cmb_lora_bw = ttk.Combobox(row_params, values=["1=125K", "2=250K", "3=500K"], width=9, state="readonly")
        self.cmb_lora_bw.set("1=125K")
        self.cmb_lora_bw.pack(side=tk.LEFT, padx=2)

        self.label(row_params, "cr_label").pack(side=tk.LEFT, padx=(10, 2))
        self.cmb_lora_cr = ttk.Combobox(row_params, values=["1=4/5", "2=4/6", "3=4/7", "4=4/8"], width=9, state="readonly")
        self.cmb_lora_cr.set("1=4/5")
        self.cmb_lora_cr.pack(side=tk.LEFT, padx=2)

        self.label(row_params, "pwr_label").pack(side=tk.LEFT, padx=(10, 2))
        self.spn_lora_pwr = ttk.Spinbox(row_params, from_=-22, to=22, width=6)
        self.spn_lora_pwr.set("20")
        self.spn_lora_pwr.pack(side=tk.LEFT, padx=2)

        row_btns = ttk.Frame(frm_lora)
        row_btns.pack(fill=tk.X, pady=(8, 2))
        self.btn_lora_read = ttk.Button(row_btns, text=self.t("read"), command=self.read_lora)
        self.btn_lora_read.pack(side=tk.LEFT, padx=5)
        self.btn_lora_write = ttk.Button(row_btns, text=self.t("write"), command=self.write_lora)
        self.btn_lora_write.pack(side=tk.LEFT, padx=5)
        self.tr["read_lora"] = (self.btn_lora_read, "read")
        self.tr["write_lora"] = (self.btn_lora_write, "write")

        # 串口参数(横向排列)
        frm_uart = ttk.LabelFrame(parent, text=self.t("uart_title"), padding=8)
        frm_uart.pack(fill=tk.X, pady=5)
        self.tr["uart_title"] = (frm_uart, "uart_title")

        row_u_params = ttk.Frame(frm_uart)
        row_u_params.pack(fill=tk.X, pady=2)
        self.label(row_u_params, "uart_baud").pack(side=tk.LEFT, padx=(0, 2))
        self.cmb_uart_baud = ttk.Combobox(row_u_params, values=["1200", "2400", "4800", "9600", "19200", "38400", "57600", "115200"], width=10)
        self.cmb_uart_baud.set("9600")
        self.cmb_uart_baud.pack(side=tk.LEFT, padx=2)

        self.label(row_u_params, "uart_stop").pack(side=tk.LEFT, padx=(15, 2))
        self.cmb_uart_stop = ttk.Combobox(row_u_params, values=UART_STOP_OPTS[self.lang], width=10, state="readonly")
        self.cmb_uart_stop.set(UART_STOP_OPTS[self.lang][0])
        self.cmb_uart_stop.pack(side=tk.LEFT, padx=2)

        self.label(row_u_params, "uart_parity").pack(side=tk.LEFT, padx=(15, 2))
        self.cmb_uart_parity = ttk.Combobox(row_u_params, values=UART_PARITY_OPTS[self.lang], width=10, state="readonly")
        self.cmb_uart_parity.set(UART_PARITY_OPTS[self.lang][0])
        self.cmb_uart_parity.pack(side=tk.LEFT, padx=2)

        row_u_btns = ttk.Frame(frm_uart)
        row_u_btns.pack(fill=tk.X, pady=(8, 2))
        self.btn_uart_read = ttk.Button(row_u_btns, text=self.t("read"), command=self.read_uart)
        self.btn_uart_read.pack(side=tk.LEFT, padx=5)
        self.btn_uart_write = ttk.Button(row_u_btns, text=self.t("write"), command=self.write_uart)
        self.btn_uart_write.pack(side=tk.LEFT, padx=5)
        self.tr["read_uart"] = (self.btn_uart_read, "read")
        self.tr["write_uart"] = (self.btn_uart_write, "write")

    def build_raw_tab(self, parent):
        self.label(parent, "raw_hint").pack(anchor=tk.W)
        self.txt_raw = tk.Text(parent, height=4, font=("Consolas", 11))
        self.txt_raw.pack(fill=tk.X, pady=5)
        self.txt_raw.insert(tk.END, "FE BB 55 25 03 00 00")

        btn_frame = ttk.Frame(parent)
        btn_frame.pack(fill=tk.X, pady=5)
        self.btn_raw_send = ttk.Button(btn_frame, text=self.t("send"), command=self.send_raw)
        self.btn_raw_send.pack(side=tk.LEFT, padx=5)
        self.btn_raw_fw = ttk.Button(btn_frame, text=self.t("gen_fw"), command=lambda: self.fill_raw(build_frame(FUNC_READ, CMD_FIRMWARE)))
        self.btn_raw_fw.pack(side=tk.LEFT, padx=5)
        self.btn_raw_lora = ttk.Button(btn_frame, text=self.t("gen_lora"), command=lambda: self.fill_raw(build_frame(FUNC_READ, CMD_LORA)))
        self.btn_raw_lora.pack(side=tk.LEFT, padx=5)
        self.tr["send"] = (self.btn_raw_send, "send")
        self.tr["gen_fw"] = (self.btn_raw_fw, "gen_fw")
        self.tr["gen_lora"] = (self.btn_raw_lora, "gen_lora")

    # ----- 语言切换 -----
    def change_language(self, event=None):
        self.lang = "en" if self.cmb_lang.get() == "English" else "zh"
        self.root.title(self.t("title"))
        # 更新静态标签
        for lbl, tkey in self.static_labels:
            lbl.config(text=self.t(tkey))
        # 更新按钮 / 框架标题
        for key, item in self.tr.items():
            if key == "open_btn":
                item.config(text=self.t("close") if (app_state.ser and app_state.ser.is_open) else self.t("open"))
            else:
                w, tkey = item
                w.config(text=self.t(tkey))
        # 更新标签页
        self.notebook.tab(0, text=self.t("tab_param"))
        self.notebook.tab(1, text=self.t("tab_raw"))
        # 更新串口枚举下拉框
        for name, opts in (("stop", UART_STOP_OPTS), ("parity", UART_PARITY_OPTS)):
            cmb = getattr(self, f"cmb_uart_{name}")
            try:
                code = int(cmb.get().split("=")[0])
            except (ValueError, IndexError):
                code = 0
            cmb["values"] = opts[self.lang]
            cmb.set(opts[self.lang][code])
        # 固件信息未读取时更新语言
        if not self.fw_read:
            self.lbl_fw.config(text=self.t("unread"))
        self.log_tr("lang_switched")

    # ----- 事件处理 -----
    def refresh_ports(self):
        ports = list_serial_ports()
        self.cmb_port["values"] = ports
        if ports:
            self.cmb_port.set(ports[0])
        else:
            self.cmb_port.set("")

    def toggle_port(self):
        if app_state.ser and app_state.ser.is_open:
            try:
                app_state.ser.close()
            except Exception as e:
                messagebox.showerror(self.t("close_fail"), self.t("close_fail") + str(e))
            self.btn_open.config(text=self.t("open"))
            self.log_tr("port_closed")
            return

        port = self.cmb_port.get()
        if not port:
            messagebox.showwarning(self.t("port_not_sel"), self.t("port_not_sel"))
            return
        try:
            databits = int(self.cmb_databits.get())
            parity = self.cmb_parity.get()
            stopbits_map = {"1": 1, "1.5": 1.5, "2": 2}
            stopbits = stopbits_map[self.cmb_stopbits.get()]
            baud = int(self.cmb_baud.get())
            app_state.ser = open_port(port, baud, databits, parity, stopbits)
            self.btn_open.config(text=self.t("close"))
            self.log(self.t("port_opened") + f"{port} @ {baud} {databits}{parity}{stopbits}")
        except Exception as e:
            messagebox.showerror(self.t("open_fail"), self.t("open_fail") + str(e))

    def get_timeout(self):
        try:
            return int(self.spn_timeout.get())
        except ValueError:
            return 500

    def ensure_open(self) -> bool:
        if app_state.ser is None or not app_state.ser.is_open:
            messagebox.showwarning(self.t("serial_req"), self.t("serial_req"))
            return False
        return True

    def fill_raw(self, frame: bytes):
        self.txt_raw.delete("1.0", tk.END)
        self.txt_raw.insert(tk.END, frame.hex(" ").upper())

    # ----- 通信接口 -----
    def tx_rx(self, frame: bytes) -> bytes:
        timeout = self.get_timeout()
        self.log(f"{self.t('tx_send')} {frame.hex(' ').upper()}")
        resp = send_and_receive(app_state.ser, frame, timeout)
        if resp:
            self.log(f"{self.t('tx_recv')} {resp.hex(' ').upper()}")
            parsed = parse_frame(resp)
            if parsed:
                status = self.t("crc_ok") if parsed["crc_ok"] else self.t("crc_fail")
                self.log(f"{self.t('parse')} func=0x{parsed['func']:02X}, cmd=0x{parsed['cmd']:04X}, data={parsed['data'].hex(' ').upper()}, {status}")
            else:
                self.log(self.t("not_protocol"))
        else:
            self.log(self.t("recv_timeout"))
        return resp

    def send_raw(self):
        if not self.ensure_open():
            return
        text = self.txt_raw.get("1.0", tk.END).strip()
        try:
            data = bytes.fromhex(text.replace(" ", "").replace(",", ""))
        except ValueError:
            messagebox.showerror(self.t("hex_invalid"), self.t("hex_invalid"))
            return
        if len(data) < 8:
            messagebox.showwarning(self.t("frame_short"), self.t("frame_short"))
            return
        if data[:4] != HEAD:
            messagebox.showwarning(self.t("not_header"), self.t("not_header"))
            return
        if len(data) >= 8 and data[-2:] == b"\x00\x00":
            if messagebox.askyesno(self.t("crc_auto_title"), self.t("crc_auto_q")):
                data = build_frame(data[4], (data[5] << 8) | data[6], data[7:-2])
                self.fill_raw(data)
        self.tx_rx(data)

    # ----- 参数读取/写入 -----
    def read_firmware(self):
        if not self.ensure_open():
            return
        frame = build_frame(FUNC_READ, CMD_FIRMWARE, )
        resp = self.tx_rx(frame)
        parsed = parse_frame(resp) if resp else None
        if parsed and parsed["crc_ok"] and parsed["cmd"] == CMD_FIRMWARE and parsed["func"] == FUNC_READ:
            data = parsed["data"]
            if len(data) >= 4:
                sw = (data[0] << 8) | data[1]
                ver = (data[2] << 8) | data[3]
                text = f"SW={sw}, Ver={ver/100:.2f}"
                self.lbl_fw.config(text=text)
                self.fw_read = True
                self.log(f"{self.t('fw_result')} {text}")
            else:
                self.lbl_fw.config(text=self.t("fw_short"))

    def read_sn(self):
        if not self.ensure_open():
            return
        frame = build_frame(FUNC_READ, CMD_SN, )
        resp = self.tx_rx(frame)
        parsed = parse_frame(resp) if resp else None
        if parsed and parsed["crc_ok"] and parsed["cmd"] == CMD_SN:
            try:
                text = parsed["data"].decode("ascii", errors="replace")
            except Exception:
                text = parsed["data"].hex().upper()
            self.entry_sn.delete(0, tk.END)
            self.entry_sn.insert(0, text)

    def write_sn(self):
        if not self.ensure_open():
            return
        text = self.entry_sn.get().encode("ascii")
        text = text[:14].ljust(14, b"\x00")
        frame = build_frame(FUNC_WRITE, CMD_SN, text)
        self.tx_rx(frame)

    def read_net_id(self):
        if not self.ensure_open():
            return
        frame = build_frame(FUNC_READ, CMD_NET_ID, )
        resp = self.tx_rx(frame)
        parsed = parse_frame(resp) if resp else None
        if parsed and parsed["crc_ok"] and parsed["cmd"] == CMD_NET_ID:
            try:
                text = parsed["data"].decode("ascii", errors="replace")
            except Exception:
                text = parsed["data"].hex().upper()
            self.entry_net.delete(0, tk.END)
            self.entry_net.insert(0, text)

    def write_net_id(self):
        if not self.ensure_open():
            return
        text = self.entry_net.get().encode("ascii")
        text = text[:14].ljust(14, b"\x00")
        frame = build_frame(FUNC_WRITE, CMD_NET_ID, text)
        self.tx_rx(frame)

    def read_lora(self):
        if not self.ensure_open():
            return
        frame = build_frame(FUNC_READ, CMD_LORA, )
        resp = self.tx_rx(frame)
        parsed = parse_frame(resp) if resp else None
        if parsed and parsed["crc_ok"] and parsed["cmd"] == CMD_LORA and len(parsed["data"]) >= 5:
            ch, sf, bw, cr, pwr = parsed["data"][:5]
            self.spn_lora_ch.set(str(ch))
            self.cmb_lora_sf.set(str(sf))
            self.cmb_lora_bw.set({1: "1=125K", 2: "2=250K", 3: "3=500K"}.get(bw, f"{bw}"))
            self.cmb_lora_cr.set({1: "1=4/5", 2: "2=4/6", 3: "3=4/7", 4: "4=4/8"}.get(cr, f"{cr}"))
            self.spn_lora_pwr.set(str(pwr))

    def write_lora(self):
        if not self.ensure_open():
            return
        try:
            ch = int(self.spn_lora_ch.get())
            sf = int(self.cmb_lora_sf.get())
            bw = int(self.cmb_lora_bw.get().split("=")[0])
            cr = int(self.cmb_lora_cr.get().split("=")[0])
            pwr = int(self.spn_lora_pwr.get())
        except ValueError:
            messagebox.showerror(self.t("lora_invalid"), self.t("lora_invalid"))
            return
        data = bytes([ch & 0xFF, sf & 0xFF, bw & 0xFF, cr & 0xFF, pwr & 0xFF])
        frame = build_frame(FUNC_WRITE, CMD_LORA, data)
        self.tx_rx(frame)

    def read_uart(self):
        if not self.ensure_open():
            return
        frame = build_frame(FUNC_READ, CMD_UART, )
        resp = self.tx_rx(frame)
        parsed = parse_frame(resp) if resp else None
        if parsed and parsed["crc_ok"] and parsed["cmd"] == CMD_UART and len(parsed["data"]) >= 6:
            baud = struct.unpack(">I", parsed["data"][:4])[0]
            stop = parsed["data"][4]
            parity = parsed["data"][5]
            self.cmb_uart_baud.set(str(baud))
            self.cmb_uart_stop.set(UART_STOP_OPTS[self.lang][stop])
            self.cmb_uart_parity.set(UART_PARITY_OPTS[self.lang][parity])

    def write_uart(self):
        if not self.ensure_open():
            return
        try:
            baud = int(self.cmb_uart_baud.get())
            stop = int(self.cmb_uart_stop.get().split("=")[0])
            parity = int(self.cmb_uart_parity.get().split("=")[0])
        except ValueError:
            messagebox.showerror(self.t("uart_invalid"), self.t("uart_invalid"))
            return
        data = struct.pack(">I", baud) + bytes([stop & 0xFF, parity & 0xFF])
        frame = build_frame(FUNC_WRITE, CMD_UART, data)
        self.tx_rx(frame)

    # ----- 日志 -----
    def log(self, msg: str):
        timestamp = time.strftime("%H:%M:%S")
        self.txt_log.insert(tk.END, f"[{timestamp}] {msg}\n")
        self.txt_log.see(tk.END)

    def log_tr(self, key: str):
        self.log(self.t(key))

    def clear_log(self):
        self.txt_log.delete("1.0", tk.END)

    def save_log(self):
        content = self.txt_log.get("1.0", tk.END).strip()
        if not content:
            messagebox.showinfo(self.t("save_log"), self.t("log_empty"))
            return
        filename = filedialog.asksaveasfilename(
            defaultextension=".txt",
            filetypes=[("Text", "*.txt"), ("All Files", "*.*")],
            title=self.t("save_log"),
        )
        if filename:
            try:
                with open(filename, "w", encoding="utf-8") as f:
                    f.write(content)
                self.log(self.t("log_saved") + filename)
            except Exception as e:
                messagebox.showerror(self.t("log_save_fail"), self.t("log_save_fail") + str(e))


def main():
    root = tk.Tk()
    app = DebuggerApp(root)
    root.mainloop()


if __name__ == "__main__":
    main()

5.2 打包脚本:build_exe.bat

在 Windows 上,把 build_exe.bat 和上面的 .py 放同一目录,双击运行 即可自动安装依赖并生成 dist\lora_modbus_tool.exe(生成后会自动打开该文件夹)。脚本会自动探测 py/python,未安装 Python 时会直接打开官网下载页。

复制代码
@echo off
chcp 65001 >nul
title 构建 LoRa 调试工具 EXE
echo ============================================
echo   RS485 / LoRa 调试工具 - EXE 一键构建
echo ============================================
echo.

set PY=
where py >nul 2>&1 && set PY=py -3
if not defined PY ( where python >nul 2>&1 && set PY=python )
if not defined PY (
    echo [错误] 未检测到 Python!
    echo 请先到 https://www.python.org/downloads/ 安装 Python 3.8+,
    echo 安装时务必勾选 "Add Python to PATH"。
    start https://www.python.org/downloads/
    pause
    exit /b 1
)

echo [1/3] 正在安装依赖 pyserial / pyinstaller ...
%PY% -m pip install --upgrade pip
%PY% -m pip install pyserial pyinstaller
if errorlevel 1 (
    echo [错误] 依赖安装失败,请检查网络连接。
    pause
    exit /b 1
)

echo.
echo [2/3] 正在打包 lora_modbus_tool.exe ...
if not exist rs485_lora_debugger.py (
    echo [错误] 未找到 rs485_lora_debugger.py,请把本脚本和它放在同一文件夹!
    pause
    exit /b 1
)
%PY% -m PyInstaller --onefile --windowed --name lora_modbus_tool --clean --noconfirm rs485_lora_debugger.py
if errorlevel 1 (
    echo [错误] 打包失败。
    pause
    exit /b 1
)

echo.
echo [3/3] 完成!EXE 文件位于 dist\lora_modbus_tool.exe
explorer dist
pause

如果你不想用 bat,直接在 cmd 执行一行也行:

复制代码
pyinstaller --onefile --windowed --name lora_modbus_tool rs485_lora_debugger.py

5.3 如何生成 exe(打包步骤详解)

生成 exe 必须在本机 Windows 上完成(打包工具链是平台相关的)。两种方式任选其一,前提都是:build_exe.batrs485_lora_debugger.py 放在同一文件夹。

方法一:双击脚本(最简单)

  1. build_exe.batrs485_lora_debugger.py 放在同一文件夹。
  2. 双击 build_exe.bat,会弹出黑色命令窗口自动执行:先安装依赖 pyserialpyinstaller,再打包。
  3. 跑完后自动打开 dist 文件夹,里面就是 lora_modbus_tool.exe

首次运行需联网下载依赖,约 1--3 分钟。窗口别关,跑完会自动停住并弹出文件夹。

方法二:手动命令(更可控)

在文件夹地址栏输入 cmd 回车,或右键"在终端中打开",依次执行:

复制代码
python -m pip install pyserial pyinstaller
python -m PyInstaller --onefile --windowed --name lora_modbus_tool rs485_lora_debugger.py

执行完,dist\lora_modbus_tool.exe 即为成品。

关键参数说明

参数 作用
--onefile 打包成单个 exe 文件
--windowed 运行时不弹黑框(GUI 程序必备)
--name lora_modbus_tool exe 文件名

常见问题

  • 双击 bat 闪退 / 提示找不到 Python :未装 Python 或没勾 PATH。去 python.org 重装并勾选 "Add Python to PATH"。
  • pip 不是内部命令:PATH 没配好,重装 Python 勾选 PATH 即可。
  • 打包很慢 / 卡住:正常现象,首次会下载并压缩依赖,等黑窗口自己结束。
  • 运行 exe 提示缺 serial 模块 :说明只是直接跑了 .py 没装依赖;用上面任一方式打包即可,或先 python -m pip install pyserial

六、使用说明

  1. 把 USB 转 RS485 模块插上,记下串口号(如 COM14)。
  2. 运行 python rs485_lora_debugger.py(或双击打包好的 exe)。
  3. 顶部选择端口、波特率等,点 打开串口
  4. 在「参数配置」页点各模块读取 ,即可把设备参数读到界面;改完点写入下发。
  5. 「原始报文」页可手动拼 HEX 帧,结尾 CRC 留 00 00 会被自动计算。
  6. 底部日志实时显示收发与解析;可清空保存为 txt。
  7. 右上角切到 English,整个界面和后续日志立即变英文。

题外话:

写这篇时我最震惊的是:这个 RS485/LoRa 调试软件,从协议解析、界面、代码、中英文切换到打包 exe,全程都是 WorkBuddy(AI 助手)独立完成的,我一行代码没写。

这让我真切感到:上位机/工控软件工程师被 AI 替代的概率大增,连嵌入式里"写调试助手、做配置工具"的活儿也受冲击。工具越便宜,会用 AI 的人越值钱。

相关推荐
钱栈up2 小时前
番茄小说榜单爬虫失效排查:从页面路由变更到API参数映射的全流程复盘
python
唐璜Taro2 小时前
Agent Harness 系列 · 第 1 篇|Agent 不只是换一个更强的模型
人工智能·python
泡茶喝茶写代码2 小时前
量化数据进阶:多维实战篇(第 11 篇):历史涨跌停价:涨跌停序列与止损线测算
java·python·股票数据api·股票数据·股票数据api接口·股票api数据接口·股票量化数据api
ikun_文3 小时前
Django开启跨域请求
python·pycharm·django
外收内放3 小时前
Python与AI应用(项目开发实战:AI智能伴侣第三版)
python·学习·ai编程
别动我齐刘海3 小时前
ROS2 Jazzy + C++ 实战路线——进阶学习3
c++·人工智能·vscode·python·算法·机器学习·机器人
Logintern093 小时前
“以一致的顺序获取锁“是预防死锁最核心、最有效的手段
python·postgresql·锁机制
打工仔折腾 AI3 小时前
Prometheus 告警推钉钉:从单群 Webhook 到跨网络 Alertmanager 实战
人工智能·后端·python·性能优化
Ivanqhz3 小时前
Ping-Pong 双缓冲
开发语言·人工智能·python·深度学习·mlir