前言
在做 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.bat 与 rs485_lora_debugger.py 放在同一文件夹。

方法一:双击脚本(最简单)
- 把
build_exe.bat和rs485_lora_debugger.py放在同一文件夹。 - 双击
build_exe.bat,会弹出黑色命令窗口自动执行:先安装依赖pyserial、pyinstaller,再打包。 - 跑完后自动打开
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。
六、使用说明
- 把 USB 转 RS485 模块插上,记下串口号(如
COM14)。 - 运行
python rs485_lora_debugger.py(或双击打包好的 exe)。 - 顶部选择端口、波特率等,点 打开串口。
- 在「参数配置」页点各模块读取 ,即可把设备参数读到界面;改完点写入下发。
- 「原始报文」页可手动拼 HEX 帧,结尾 CRC 留
00 00会被自动计算。 - 底部日志实时显示收发与解析;可清空 或保存为 txt。
- 右上角切到
English,整个界面和后续日志立即变英文。
题外话:
写这篇时我最震惊的是:这个 RS485/LoRa 调试软件,从协议解析、界面、代码、中英文切换到打包 exe,全程都是 WorkBuddy(AI 助手)独立完成的,我一行代码没写。
这让我真切感到:上位机/工控软件工程师被 AI 替代的概率大增,连嵌入式里"写调试助手、做配置工具"的活儿也受冲击。工具越便宜,会用 AI 的人越值钱。