文章目录
1. 项目介绍2. 功能简介3. 开发过程3.1 准备工作3.2 编写串口上位机界面3.3 功能实现3.3.1 基本功能3.3.2 整活3.4 打包 exe 可执行文件1. 项目介绍
该项目为本人的一次课设,在很多项目开发中,都需要通过上位机来控制或者读取 MCU、MPU 中的数据。上位机和设备间的通信协议有串口、CAN、RS485 等等。本项目基于 python 编写,将串口获取到的数据显示在上位机中,并将数据以可视化图形显示出来。废话少说,上图!!!
2. 功能简介
3. 开发过程
3.1 准备工作
本项目用到的库有 tkinter、pyserial、matplotlib、pyautogui、configparser、webbrowser 等,其中 pyserial 与 pyautogui 需要自行安装其余库皆是 python 自带库。如没有安装过这两个库可以使用以下命令安装。
pip install pyserialpip install pyautogui
3.2 编写串口上位机界面
首先,先将上位机基本界面框架搭建好,此部分给出代码自行研究。
from tkinter import *from tkinter.messagebox import *import ctypesclass zsh_serial:def __init__(self):self.window = Tk() # 实例化出一个父窗口# = serial.Serial()self.serial_combobox = Noneself.bound_combobox = Noneself.txt = Nonedef ui(self):############################################# 窗口配置############################################# self.window = Tk() # 实例化出一个父窗口self.window.title("温湿度串口调试助手")# self.window.iconbitmap(default='data\\COM.ico') # 修改 logowidth = self.window.winfo_screenwidth()height = self.window.winfo_screenheight()print(width, height)win = '{}x{}+{}+{}'.format(880, 500, width // 3, height // 5) # {}x{} 窗口大小,+10 +10 定义窗口弹出时的默认展示位置self.window.geometry(win)self.window.resizable(False, False)# 调用api设置成由应用程序缩放ctypes.windll.shcore.SetProcessDpiAwareness(1)# 调用api获得当前的缩放因子ScaleFactor = ctypes.windll.shcore.GetScaleFactorForDevice(0)# 设置缩放因子self.window.tk.call('tk', 'scaling', ScaleFactor / 75)############################################# 串口设置子菜单 1############################################# 串口设置group_serial_set = LabelFrame(self.window, text="串口设置")group_serial_set.grid(row=0, padx=10, pady=10)serial_label = Label(group_serial_set, text="串口号")serial_label.grid(row=0, column=0, padx=10, pady=10, sticky=W)self.serial_combobox = bobox(group_serial_set, width=8)# self.serial_combobox['value'] = zsh_serial.getSerialPort()self.serial_combobox.grid(row=0, column=1, padx=10, pady=10)bound_label_set = Label(group_serial_set, text="波特率")bound_label_set.grid(row=1, column=0)self.bound_combobox = bobox(group_serial_set, width=8)self.bound_combobox['value'] = ("9600", "19200", "38400", "57600", "115200", "128000")self.bound_combobox.grid(row=1, column=1)databits_label = Label(group_serial_set, text="数据位")databits_label.grid(row=2, column=0, pady=10)databits_combobox = bobox(group_serial_set, width=8)databits_combobox['value'] = ("1", "1.5", "2")databits_combobox.grid(row=2, column=1)checkbits_label = Label(group_serial_set, text="校验位")checkbits_label.grid(row=3, column=0)checkbits_combobox = bobox(group_serial_set, width=8)checkbits_combobox['value'] = ("None", "Odd", "Even")checkbits_combobox.grid(row=3, column=1)xxx_label = Label(group_serial_set, text=" ")xxx_label.grid(row=4, column=0, pady=1)# 接收设置recv_set = LabelFrame(self.window, text="接收设置")recv_set.grid(row=1, padx=10)recv_set_v = IntVar()recv_set_radiobutton1 = Radiobutton(recv_set, text="ASCII", variable=recv_set_v, value=1)recv_set_radiobutton1.grid(row=0, column=0, sticky=W, padx=10)recv_set_radiobutton2 = Radiobutton(recv_set, text="HEX", variable=recv_set_v, value=2)recv_set_radiobutton2.grid(row=0, column=1, sticky=W, padx=10)recv_set_v1 = IntVar()recv_set_v2 = IntVar()recv_set_v3 = IntVar()recv_set_checkbutton1 = Checkbutton(recv_set, text="自动换行", variable=recv_set_v1, onvalue=1, offvalue=2)recv_set_checkbutton1.grid(row=1, column=0, padx=10)recv_set_checkbutton2 = Checkbutton(recv_set, text="显示发送", variable=recv_set_v2, onvalue=1, offvalue=2)recv_set_checkbutton2.grid(row=2, column=0, padx=10)recv_set_checkbutton3 = Checkbutton(recv_set, text="显示时间", variable=recv_set_v3, onvalue=1, offvalue=2)recv_set_checkbutton3.grid(row=3, column=0, padx=10)# 串口操作group_serial_event = LabelFrame(self.window, text="串口操作")group_serial_event.grid(row=2, padx=10, pady=10)self.serial_btn_flag_str = StringVar()self.serial_btn_flag_str.set("串口未打开")label_name = Label(group_serial_event, textvariable=self.serial_btn_flag_str, bg='#ff001a', fg='#ffffff')label_name.grid(row=0, column=0, padx=55, pady=2)self.serial_btn_str = StringVar()self.serial_btn_str.set("打开串口")serial_btn = Button(group_serial_event, textvariable=self.serial_btn_str)serial_btn.grid(row=1, column=0, padx=55, pady=10)# 数据显示self.txt = Text(self.window, width=70, height=26.5, font=("SimHei", 10))self.txt.grid(row=0, rowspan=3, column=1, padx=8, pady=10, sticky='s')# 串口子菜单设置初值self.bound_combobox.set(self.bound_combobox['value'][4])databits_combobox.set(databits_combobox['value'][0])checkbits_combobox.set(checkbits_combobox['value'][0])recv_set_v.set(2)recv_set_v1.set(1)recv_set_v2.set(2)recv_set_v3.set(2)############################################# 配置tkinter样式############################################# self.window.config(menu=menubar)############################################# 退出检测############################################def bye():self.window.destroy()self.window.protocol("WM_DELETE_WINDOW", bye)# 窗口循环显示self.window.mainloop()if __name__ == "__main__":mySerial = zsh_serial()mySerial.ui()
PS:当前版本仅支持 125%缩放 1920x1080分辨率 or 2560x1440分辨率
现在界面还是太简陋了,接下来增加 menu 菜单栏。这里用到了 ttk 子模块,因为 tkinter 没有下拉菜单控件,代码如下:
from tkinter import ttk # 导入ttk模块,因为Combobox下拉菜单控件在ttk中# ... 略############################################# menu菜单############################################menubar = Menu(self.window) # 创建一个顶级菜单menu = MENU(self.window)filemenu1 = Menu(menubar, tearoff=False) # 在顶级菜单menubar下, 创建一个子菜单filemenu1filemenu2 = Menu(menubar, tearoff=False) # 在顶级菜单menubar下, 创建一个子菜单filemenu2filemenu3 = Menu(menubar, tearoff=False) # 在顶级菜单menubar下, 创建一个子菜单filemenu3menubar.add_cascade(label="文件", menu=filemenu1) # 为子菜单filemenu1取个名字menubar.add_cascade(label="工具", menu=filemenu2) # 为子菜单filemenu2取个名字menubar.add_cascade(label="折线图", menu=filemenu3) # 为子菜单filemenu3取个名字menubar.add_command(label="帮助", command=menu.callback7)menubar.add_command(label="关于", command=menu.callback8)filemenu1.add_command(label="更新检测", command=menu.callback9) # 为子菜单filemenu1添加选项,取名"更新检测"filemenu1.add_command(label="获取源码", command=menu.callback1) # 为子菜单filemenu1添加选项,取名"获取源码"filemenu1.add_command(label="博客教程", command=menu.callback10) # 为子菜单filemenu1添加选项,取名"博客教程"filemenu1.add_separator() # 添加一条分割线filemenu1.add_command(label="退出", command=menu.callback2) # 为子菜单filemenu1添加选项,取名"关闭"filemenu2.add_command(label="刷新串口", command=self.cleanSerial) # 为子菜单filemenu2添加选项,取名"刷新串口"filemenu2.add_command(label="截图", command=menu.callback4) # 为子菜单filemenu2添加选项,取名"截图"filemenu3.add_command(label="温度图", command=menu.callback5) # 为子菜单filemenu2添加选项,取名"温度图"filemenu3.add_command(label="湿度图", command=menu.callback6) # 为子菜单filemenu2添加选项,取名"湿度图"# ... 略
这一步完成后,是运行不了的,我们要为菜单栏增加回调函数。
import webbrowserclass MENU:def __init__(self, init_window_name):self.init_window_name = init_window_name@staticmethoddef callback1():print("--- 获取源码 ---")showwarning("warning", "Please follow the GPL3.0")webbrowser.open("/Theo-s-Open-Source-Project")@staticmethoddef callback2():print("--- 退出 ---")sys.exit()def callback3(self):print("--- 刷新串口 ---")@staticmethoddef callback4():print("--- 截图 ---")# window_capture()# ... 略
到此,我们的界面已经搭建完成了,接下来就是注入灵魂的时候,为其增加功能函数。
3.3 功能实现
3.3.1 基本功能
在进行通信前,要先获取电脑可用串口进行连接,借助 pyserial 库的serial.ports()
获取电脑目前所有串口号。
@staticmethoddef getSerialPort():port = []portList = list(serial.ports())# print(portList)if len(portList) == 0:print("--- 无串口 ---")port.append('None')else:for comport in portList:# print(list(comport)[0])# print(list(comport)[1])port.append(list(comport)[0])passreturn port
获取到串口号后,将其显示在 tkinter 的 combobox 控件中。
self.serial_combobox['value'] = zsh_serial.getSerialPort()
接下来就是打开串口,这里不做详细讲解(如需要的话评论区留言🦄)给出具体实现代码。
def openSerial(self, port, bps, timeout):"""打开串口:param port: 端口号:param bps: 波特率:param timeout: 超时时间:return: True or False"""ser_flag = Falsetry:# 打开串口 = serial.Serial(port, bps, timeout=timeout)if .isOpen():ser_flag = True# threading.Thread(target=self.readSerial, args=(,)).start()# print("Debug: 串口已打开\n")# else:#print("Debug: 串口未打开")except Exception as e:print("error: ", e)error = "error: {}".format(e)showerror('error', error)return , ser_flag
将其与打开串口 button 事件进行绑定,代码如下:
... self.serial_btn_str = StringVar()self.serial_btn_str.set("打开串口")serial_btn = Button(group_serial_event, textvariable=self.serial_btn_str, command=self.hit1) # 添加点击事件serial_btn.grid(row=1, column=0, padx=55, pady=10)...def hit1(self):"""打开串口按钮回调"""# print(.isOpen())if .isOpen():.close()print("--- 串口未打开 ---")self.serial_btn_flag_str.set("串口未打开")self.serial_btn_str.set("打开串口")else:, ser_flag = self.openSerial(self.serial_combobox.get(), self.bound_combobox.get(), None)if ser_flag:print("--- 串口已打开 ---")self.serial_btn_flag_str.set("串口已打开")self.serial_btn_str.set("关闭串口")
到此,一个串口调试助手的最基本功能就实现了,接下来就是让串口获取到的信息显示到上位机中的 txt 控件上。
我们该如何实时获取并打印串口中的数据呢,这里使用一个线程不断的去读取。
def readSerial(self, com):"""读取串口数据:return:"""global serialDatawhile True:if .in_waiting:textSetial = .read(.in_waiting)serialData = textSetial# print(textSetial)self.txt.config(state=NORMAL)self.txt.insert(END, textSetial)self.txt.config(state=DISABLED)# print("Debug: thread_readSerial is running")
基本功能实现,但现在的上位机还是太单调了,接下来就是整活时间😋
3.3.2 整活
在最开始时,我们创建了一行菜单栏,接下来为其注入灵魂!
首先是这款上位机的重中之重”折线图“(注:当前版本的折线图数据非串口获取到到真实数据,仅做功能演示!!)
def createTempWindow(self):"""创建新的窗口"""new_window = self.windownew_window.title("温度折线图")new_window.geometry("720x480")# Button(new_window,# text="This is new window").pack()# 创建一个容器, 没有画布时的背景frame = Frame(new_window, bg="#ffffff")frame.place(x=0, y=0, width=720, height=480)plt.rcParams['font.sans-serif'] = ['SimHei'] # 用来正常显示中文标签fig = plt.figure(figsize=(6, 3.9), edgecolor='blue')# 定义刻度ax = fig.add_subplot(111)ax.set(xlim=[0, 121], ylim=[0, 40], title="温度折线图", ylabel='温度/°C')canvas = FigureCanvasTkAgg(fig, master=frame)canvas.draw()# 显示画布canvas.get_tk_widget().place(x=0, y=0)# 定义存储坐标的空数组self.i = 0self.x = []self.y = []def drawTemp():global tempDataself.i += 1# time.sleep(1)ax.clear()ax.set(xlim=[0, 121], ylim=[0, 40], title="温度折线图", ylabel='温度/°C')t = self.iif t >= 120:bye()dtax = tself.x.append(dtax)# 温度数据处理"""xxxxxxxxxxxxxxxxxxxxx"""dtay = random.randint(22, 36)# print(dtay)self.y.append(dtay)ax.plot(self.x, self.y)canvas.draw()self.afterHandler = self.window.after(100, drawTemp)drawTemp()def bye():plt.close('all')new_window.destroy()self.window.mainloop()new_window.protocol("WM_DELETE_WINDOW", bye)# 窗口循环显示new_window.mainloop()
将其与菜单栏的回调进行绑定,这里加了一个专业版和社区版的识别函数(是不是有 B 格起来了😎)
@staticmethoddef callback5():config = version.config()if config['power'] == 'Professional':print("--- 温度折线图 ---")new_win = zsh_serial()new_win.createTempWindow()
通过读取存放在 config.ini 中的 JSON 数据进行分析判断是专业版还是社区版来赋予访问折线图的权限。
from configparser import ConfigParserclass version:@staticmethoddef config():"""获取配置文件:return: 读取到的配置文件信息"""config = ConfigParser()config.read("src\\config.ini")cfg = dict(config.items("config")) # 字符串转换为字典# print(cfg)# print(cfg['version'])return cfg
社区版会弹出提示框,这里放的二维码是俺的博客地址。实现方法也非常简单,简单来说就是新建一个窗口并显示。这里需要注意的是 tkinter 库的 PhotoImage 函数只能显示 gif 格式的图片,所以需要进行一个图片格式转换。
保存串口信息功能(如下图),实现方法其实很简单,因为在前面将 txt 窗口设为只读模式,所以 copy 串口打印信息时,需要将 txt 控件解除只读,为了保证串口数据不被人为的误改, get 数据后再将其恢复为只读模式。将获取到的数据保存到 txt 文件中,默认保存路径位桌面,这里用到了os.getlogin()
获取系统用户名。
def window_save(self):self.txt.config(state=NORMAL)result = self.txt.get("1.0", "end")self.txt.config(state=DISABLED)with open('C:\\Users\\{}\\Desktop\\zshSerial.txt'.format(os.getlogin()), 'w') as f:for text in result:f.write(text)
还记得上面有提到过的更新检测吗(好像没有提到过bushi 😎),通过对比服务器上的版本信息进行判断,如果有时间下个版本会更新在线升级(咕咕~🕊)。
@staticmethoddef callback9():print("--- 更新检测 ---")import requestsver = requests.get('http://xxx.xxx.xxx.xx/download/open-source-project/zshSerial/version.txt')print(ver.text)config = version.config()if "lastest: v{}".format(config['version']) == ver.text:versionCheck = "当前版本:v{} 为最新版".format(config['version'])showinfo('更新检测', versionCheck)else:versionCheck = "当前版本:v{} 版本过低,请及时更新".format(config['version'])showwarning('更新检测', versionCheck)version.update()
3.4 打包 exe 可执行文件
首先,我们从 GitHub 仓库将源码克隆到本地。
git clone /Theo-s-Open-Source-Project/zshSerial.git
克隆下来的文件夹结构如下:
.├── data //存放一些数据│ ├──COM.png│ ├──blog.gif│ ├──xxx.ico│ ├──readme.txt├── image //README文件的图片├── src //zshSerial库│ ├──__pycache__│ ├──config.ini│ ├──parameter.py│ ├──ui.py├── .gitignore ├── LICENSE ├── README.md //README文档├── zshSerial.py //main函数
准备好后,(这里使用 pyinstaller 打包)打开终端,输入以下命令进行打包。
pyinstaller -D -w -i 1.ico zshSerial.py
打包好的程序存放在当前目录的 dist 文件夹下,用 pyinstaller 打包的文件夹有些许大,这里有几种方法可以压缩(自行百度),but 俺懒得尝试了,使用最简单粗暴的方法 delete!!! 哈哈哈。确保 exe 文件有在运行,选中所有的 .dll 文件 delete 即可。
如果觉得这篇文章对您有帮助可以 Github 给个小星星谢谢大伙!😋
/Theo-s-Open-Source-Project/zshSerial <=【源码下载链接】
PS:当前版本仅支持 125%缩放 1920x1080分辨率 or 2560x1440分辨率