做过工控项目的朋友都懂——凌晨两点,车间里几十路IO信号乱跑,你盯着一堆0和1傻眼,完全不知道哪个点位对应哪台设备、哪个传感器。
这不是段子。这是我头两年做PLC上位机时的真实写照。
当时最大的问题不是"信号读不到",而是**"读到了但不知道这是什么"**。工程图纸厚厚一叠,IO地址表密密麻麻,翻来翻去还容易翻错页。更别提现场调试时,甲方工程师站在旁边催你,你手忙脚乱地查表对地址……那滋味,真不好受。
后来我琢磨了一个方向:用Tkinter做一个可视化的IO点位映射面板,把所有点位、状态、描述信息一屏显示,实时刷新,还能按区域分组。投入不大,但现场调试效率直接翻倍。
今天这篇文章,就把这套东西从头讲清楚。不绕弯子,直接上干货。
一个中等规模的自动化项目,IO点位少则几十、多则几百。DI(数字输入)、DO(数字输出)、AI(模拟输入)、AO(模拟输出)混在一起,地址命名还各家厂商各有套路。
工程师看地址Q0.0或%MW100,脑子里得先过一遍"这是什么",这个翻译过程才是效率杀手。
IO信号变化是毫秒级的。用print打日志?刷屏看不过来。用Excel记录?事后才能分析。真正需要的是实时的、有颜色区分的状态可视化——亮绿就是1,暗灰就是0,一眼扫过去全知道。
很多人第一次写上位机,把IO读取逻辑、界面刷新逻辑、数据处理全塞一个函数里。项目小还好,一旦点位增加,改一处就崩一片。这是设计问题,不是技术问题。
在动手写代码之前,先把架构想清楚,后面会省很多事。
咱们这套IO映射面板的设计核心,就三个字:分、映、刷。
这三件事分清楚,代码自然就清爽了。
先从最简单的开始。把IO点位信息硬编码进去,用Canvas或Frame画出状态指示灯,跑通整个流程。
import tkinter as tkfrom tkinter import ttkimport randomimport threadingimport time# ─────────────────────────────────────────# IO点位配置表:地址 → (描述, 区域, 类型)# 实际项目里这张表从数据库或配置文件读# ─────────────────────────────────────────IO_MAP = {"DI_0001": ("进料传感器A", "进料区", "DI"),"DI_0002": ("进料传感器B", "进料区", "DI"),"DI_0003": ("安全门状态", "进料区", "DI"),"DO_0001": ("进料电机启动", "进料区", "DO"),"DO_0002": ("警示灯红", "进料区", "DO"),"DI_0010": ("夹具到位", "加工区", "DI"),"DI_0011": ("刀具原点", "加工区", "DI"),"DO_0010": ("主轴启动", "加工区", "DO"),"DO_0011": ("冷却泵", "加工区", "DO"),"DI_0020": ("出料到位", "出料区", "DI"),"DO_0020": ("推料气缸", "出料区", "DO"),"DO_0021": ("传送带正转", "出料区", "DO"),}# 模拟IO状态(真实项目里从PLC读取)io_state = {addr: 0for addr in IO_MAP}classIOPointWidget(tk.Frame):"""单个IO点位的可视化组件"""# 颜色配置:类型 × 状态 COLOR_MAP = {"DI": {1: "#2ECC71", 0: "#1A3A2A"}, # 绿色系"DO": {1: "#E74C3C", 0: "#3A1A1A"}, # 红色系 }def__init__(self, parent, addr, desc, io_type, **kwargs):super().__init__(parent, bg="#1E1E2E", **kwargs)self.addr = addrself.io_type = io_typeself._state = 0# 状态指示灯(Canvas圆形)self.canvas = tk.Canvas(self, width=18, height=18, bg="#1E1E2E", highlightthickness=0)self.canvas.pack(side=tk.LEFT, padx=(4, 6), pady=4)self.led = self.canvas.create_oval(2, 2, 16, 16, fill=self.COLOR_MAP[io_type][0], outline="#333")# 地址标签 tk.Label(self, text=addr, font=("Consolas", 9), fg="#7F8C8D", bg="#1E1E2E", width=10, anchor="w").pack(side=tk.LEFT)# 描述标签 tk.Label(self, text=desc, font=("微软雅黑", 9), fg="#ECF0F1", bg="#1E1E2E", width=12, anchor="w").pack(side=tk.LEFT)# 类型徽章 badge_color = "#27AE60"if io_type == "DI"else"#C0392B" tk.Label(self, text=io_type, font=("Consolas", 8, "bold"), fg="white", bg=badge_color, padx=4).pack(side=tk.LEFT, padx=4)defset_state(self, state: int):"""更新点位状态(0/1)"""if state == self._state:return# 没变化就跳过,减少重绘self._state = state color = self.COLOR_MAP[self.io_type][state]self.canvas.itemconfig(self.led, fill=color)classIOPanelApp(tk.Tk):def__init__(self):super().__init__()self.title("IO点位映射监控面板 v1.0")self.configure(bg="#12121F")self.geometry("780x620")self.resizable(True, True)self._widgets: dict[str, IOPointWidget] = {}self._build_ui()self._start_refresh_thread()def_build_ui(self):# 顶部标题栏 header = tk.Frame(self, bg="#0D0D1A", height=50) header.pack(fill=tk.X) tk.Label(header, text="⚡ IO点位实时监控", font=("微软雅黑", 14, "bold"), fg="#F39C12", bg="#0D0D1A").pack(side=tk.LEFT, padx=16, pady=10)self.status_label = tk.Label(header, text="● 运行中", font=("微软雅黑", 10), fg="#2ECC71", bg="#0D0D1A")self.status_label.pack(side=tk.RIGHT, padx=16)# 主体滚动区域 container = tk.Frame(self, bg="#12121F") container.pack(fill=tk.BOTH, expand=True, padx=10, pady=8) canvas = tk.Canvas(container, bg="#12121F", highlightthickness=0) scrollbar = ttk.Scrollbar(container, orient="vertical", command=canvas.yview)self.scroll_frame = tk.Frame(canvas, bg="#12121F")self.scroll_frame.bind("<Configure>",lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) canvas.create_window((0, 0), window=self.scroll_frame, anchor="nw") canvas.configure(yscrollcommand=scrollbar.set) canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) scrollbar.pack(side=tk.RIGHT, fill=tk.Y)# 按区域分组渲染 groups: dict[str, list] = {}for addr, (desc, zone, iotype) in IO_MAP.items(): groups.setdefault(zone, []).append((addr, desc, iotype))for zone, points in groups.items():self._render_group(zone, points)# 底部统计栏self._build_status_bar()def_render_group(self, zone: str, points: list):"""渲染一个区域分组"""# 区域标题 zone_frame = tk.Frame(self.scroll_frame, bg="#1A1A2E", relief=tk.FLAT, bd=0) zone_frame.pack(fill=tk.X, padx=6, pady=(10, 2)) tk.Label(zone_frame, text=f"📦 {zone}", font=("微软雅黑", 11, "bold"), fg="#3498DB", bg="#1A1A2E", pady=6, padx=10).pack(side=tk.LEFT) tk.Label(zone_frame, text=f"{len(points)} 个点位", font=("微软雅黑", 9), fg="#7F8C8D", bg="#1A1A2E").pack(side=tk.RIGHT, padx=10)# 分割线 sep = tk.Frame(self.scroll_frame, bg="#2C3E50", height=1) sep.pack(fill=tk.X, padx=6)# 点位列表for addr, desc, iotype in points: widget = IOPointWidget(self.scroll_frame, addr, desc, iotype) widget.pack(fill=tk.X, padx=12, pady=1)self._widgets[addr] = widgetdef_build_status_bar(self): bar = tk.Frame(self, bg="#0D0D1A", height=30) bar.pack(fill=tk.X, side=tk.BOTTOM)self.stat_label = tk.Label(bar, text="", font=("Consolas", 9), fg="#95A5A6", bg="#0D0D1A")self.stat_label.pack(side=tk.LEFT, padx=12)def_start_refresh_thread(self):"""后台线程:模拟IO状态变化"""defworker():whileTrue:# 真实项目里,这里换成读PLC/Modbus/OPC-UA的代码for addr in io_state:if random.random() < 0.08: # 8%概率翻转 io_state[addr] ^= 1# 通过after()回到主线程更新界面——这是关键!self.after(0, self._refresh_ui) time.sleep(0.5) t = threading.Thread(target=worker, daemon=True) t.start()def_refresh_ui(self):"""主线程刷新所有点位状态""" on_count = 0for addr, widget inself._widgets.items(): state = io_state.get(addr, 0) widget.set_state(state) on_count += state total = len(self._widgets)self.stat_label.config( text=f"总点位: {total} | 激活: {on_count} | "f"未激活: {total - on_count} | "f"刷新: {time.strftime('%H:%M:%S')}" )if __name__ == "__main__": app = IOPanelApp() app.mainloop()
⚠️ 踩坑预警:绝对不要在后台线程里直接操作Tkinter控件!Tkinter不是线程安全的,这么干迟早崩。正确姿势是后台线程计算好数据,用
self.after(0, callback)派发到主线程执行。这条规则记死了。
硬编码的IO_MAP在小项目还凑合,一旦点位上百,就得换个活法。这一版把点位配置写进JSON,还加了搜索框,现场查点位贼方便。
import json import tkinter as tk from tkinter import ttk import random import threading import time import os defcreate_sample_config(): """创建示例配置文件""" sample_config = { "DI_0001": {"desc": "进料传感器A", "zone": "进料区", "type": "DI"}, "DI_0002": {"desc": "进料传感器B", "zone": "进料区", "type": "DI"}, "DI_0003": {"desc": "安全门状态", "zone": "进料区", "type": "DI"}, "DO_0001": {"desc": "进料电机启动", "zone": "进料区", "type": "DO"}, "DO_0002": {"desc": "警示灯红", "zone": "进料区", "type": "DO"}, "DI_0010": {"desc": "夹具到位", "zone": "加工区", "type": "DI"}, "DI_0011": {"desc": "刀具原点", "zone": "加工区", "type": "DI"}, "DI_0012": {"desc": "工件检测", "zone": "加工区", "type": "DI"}, "DO_0010": {"desc": "主轴启动", "zone": "加工区", "type": "DO"}, "DO_0011": {"desc": "冷却泵", "zone": "加工区", "type": "DO"}, "DO_0012": {"desc": "夹紧气缸", "zone": "加工区", "type": "DO"}, "DI_0020": {"desc": "出料到位", "zone": "出料区", "type": "DI"}, "DI_0021": {"desc": "堆垛满载", "zone": "出料区", "type": "DI"}, "DO_0020": {"desc": "推料气缸", "zone": "出料区", "type": "DO"}, "DO_0021": {"desc": "传送带正转", "zone": "出料区", "type": "DO"}, "DO_0022": {"desc": "分拣机构", "zone": "出料区", "type": "DO"}, "DI_0030": {"desc": "急停按钮", "zone": "安全区", "type": "DI"}, "DI_0031": {"desc": "光栅保护", "zone": "安全区", "type": "DI"}, "DO_0030": {"desc": "报警蜂鸣器", "zone": "安全区", "type": "DO"}, "DO_0031": {"desc": "状态指示灯", "zone": "安全区", "type": "DO"}, } withopen("io_config.json", "w", encoding="utf-8") as f: json.dump(sample_config, f, ensure_ascii=False, indent=2) defload_io_config(path: str) -> dict: """从JSON加载IO配置,容错处理"""try: withopen(path, "r", encoding="utf-8") as f: return json.load(f) except FileNotFoundError: print(f"[警告] 配置文件不存在: {path},创建示例配置") create_sample_config() return load_io_config(path) except json.JSONDecodeError as e: print(f"[错误] JSON格式有问题: {e}") return {} classIOPointWidget(tk.Frame): """单个IO点位的可视化组件""" COLOR_MAP = { "DI": {1: "#2ECC71", 0: "#1A3A2A"}, "DO": {1: "#E74C3C", 0: "#3A1A1A"}, } def__init__(self, parent, addr, desc, io_type, zone, **kwargs): super().__init__(parent, bg="#1E1E2E", relief=tk.FLAT, bd=1, **kwargs) self.addr = addr self.desc = desc self.io_type = io_type self.zone = zone self._state = 0self.bind("<Enter>", lambda e: self.config(bg="#252540")) self.bind("<Leave>", lambda e: self.config(bg="#1E1E2E")) self._build_widget() def_build_widget(self): # 状态指示灯 self.canvas = tk.Canvas(self, width=16, height=16, bg="#1E1E2E", highlightthickness=0) self.canvas.pack(side=tk.LEFT, padx=(8, 10), pady=6) self.led = self.canvas.create_oval(2, 2, 14, 14, fill=self.COLOR_MAP[self.io_type][0], outline="#444") # 地址标签 addr_label = tk.Label(self, text=self.addr, font=("Consolas", 9, "bold"), fg="#7F8C8D", bg="#1E1E2E", width=10, anchor="w") addr_label.pack(side=tk.LEFT, padx=(0, 8)) # 描述标签 desc_label = tk.Label(self, text=self.desc, font=("微软雅黑", 9), fg="#ECF0F1", bg="#1E1E2E", width=14, anchor="w") desc_label.pack(side=tk.LEFT, padx=(0, 8)) # 类型徽章 badge_color = "#27AE60"ifself.io_type == "DI"else"#C0392B" type_label = tk.Label(self, text=self.io_type, font=("Consolas", 8, "bold"), fg="white", bg=badge_color, padx=6, pady=1) type_label.pack(side=tk.LEFT, padx=(0, 8)) # 区域标签 zone_label = tk.Label(self, text=self.zone, font=("微软雅黑", 8), fg="#BDC3C7", bg="#34495E", padx=6, pady=1) zone_label.pack(side=tk.LEFT) # 状态文本 self.state_label = tk.Label(self, text="OFF", font=("Consolas", 8, "bold"), fg="#7F8C8D", bg="#1E1E2E", width=4) self.state_label.pack(side=tk.RIGHT, padx=8) # 鼠标事件绑定 for widget in [self.canvas, addr_label, desc_label, type_label, zone_label, self.state_label]: widget.bind("<Enter>", lambda e: self.config(bg="#252540")) widget.bind("<Leave>", lambda e: self.config(bg="#1E1E2E")) defset_state(self, state: int): """更新点位状态"""if state == self._state: returnself._state = state color = self.COLOR_MAP[self.io_type][state] self.canvas.itemconfig(self.led, fill=color) text = "ON "if state else"OFF" color = "#2ECC71"if state else"#7F8C8D"self.state_label.config(text=text, fg=color) classZoneSection: """区域分组容器,管理区域标题和点位"""def__init__(self, parent, zone_name: str): self.zone_name = zone_name self.parent = parent self.widgets = {} # 本区域的IO点位控件 # 创建区域框架 self.zone_frame = tk.Frame(parent, bg="#1A1A2E", relief=tk.FLAT, bd=0) # 区域标题 self.title_label = tk.Label(self.zone_frame, text=f"📦 {zone_name}", font=("微软雅黑", 11, "bold"), fg="#3498DB", bg="#1A1A2E", pady=6, padx=12) self.title_label.pack(side=tk.LEFT) # 点位计数 self.count_label = tk.Label(self.zone_frame, text="0 个点位", font=("微软雅黑", 9), fg="#7F8C8D", bg="#1A1A2E") self.count_label.pack(side=tk.RIGHT, padx=12) # 分割线 self.separator = tk.Frame(parent, bg="#2C3E50", height=1) self.is_visible = Falsedefadd_widget(self, addr: str, widget: IOPointWidget): """添加IO点位控件到本区域"""self.widgets[addr] = widget self._update_count() def_update_count(self): """更新点位计数显示""" count = len(self.widgets) self.count_label.config(text=f"{count} 个点位") defshow(self): """显示区域(标题和分割线)"""ifnotself.is_visible: self.zone_frame.pack(fill=tk.X, padx=4, pady=(8, 2)) self.separator.pack(fill=tk.X, padx=4) self.is_visible = Truedefhide(self): """隐藏区域"""ifself.is_visible: self.zone_frame.pack_forget() self.separator.pack_forget() self.is_visible = Falsedeffilter_widgets(self, keyword: str) -> int: """根据关键词过滤本区域的控件,返回可见数量""" visible_count = 0 has_visible = Falsefor addr, widget inself.widgets.items(): # 判断是否匹配搜索条件 desc = widget.desc.lower() zone = widget.zone.lower() visible = (keyword == ""or keyword in addr.lower() or keyword in desc or keyword in zone) if visible: widget.pack(fill=tk.X, padx=8, pady=1) visible_count += 1 has_visible = Trueelse: widget.pack_forget() # 根据是否有可见控件决定显示/隐藏区域标题 if has_visible: self.show() else: self.hide() return visible_count classSearchableIOPanel(tk.Frame): """带搜索过滤的IO面板组件(修复版)"""def__init__(self, parent, io_config: dict, **kwargs): super().__init__(parent, bg="#12121F", **kwargs) self.io_config = io_config self.widgets = {} # 所有IO控件 {addr: widget} self.zones = {} # 区域管理器 {zone_name: ZoneSection} self._search_var = tk.StringVar() self.stats_label = Noneself.search_entry = Noneself._build() self._create_widgets() # 设置搜索监听 self._search_var.trace_add("write", self._on_search) def_build(self): # 搜索栏 search_container = tk.Frame(self, bg="#0D0D1A", pady=8) search_container.pack(fill=tk.X, padx=8, pady=8) search_frame = tk.Frame(search_container, bg="#1E1E2E", relief=tk.FLAT, bd=1) search_frame.pack(fill=tk.X) tk.Label(search_frame, text="🔍", bg="#1E1E2E", fg="#7F8C8D", font=("", 14)).pack(side=tk.LEFT, padx=(8, 4)) self.search_entry = tk.Entry(search_frame, textvariable=self._search_var, font=("微软雅黑", 10), bg="#2C3E50", fg="white", insertbackground="white", relief=tk.FLAT, bd=0) self.search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(4, 8), pady=6) # 占位符 self.placeholder = "输入地址或描述搜索..."self.search_entry.insert(0, self.placeholder) self.search_entry.bind("<FocusIn>", self._on_entry_focus_in) self.search_entry.bind("<FocusOut>", self._on_entry_focus_out) # 清空按钮 clear_btn = tk.Label(search_frame, text="✕", bg="#1E1E2E", fg="#7F8C8D", font=("", 12), cursor="hand2") clear_btn.pack(side=tk.RIGHT, padx=8) clear_btn.bind("<Button-1>", lambda e: self._clear_search()) # 统计标签 self.stats_label = tk.Label(search_container, text="正在加载...", font=("微软雅黑", 9), fg="#7F8C8D", bg="#0D0D1A") self.stats_label.pack(pady=(4, 0)) # 滚动区域 self._create_scroll_area() def_create_scroll_area(self): """创建滚动区域""" container = tk.Frame(self, bg="#12121F") container.pack(fill=tk.BOTH, expand=True, padx=8) self.canvas = tk.Canvas(container, bg="#12121F", highlightthickness=0) scrollbar = ttk.Scrollbar(container, orient="vertical", command=self.canvas.yview) self.scroll_frame = tk.Frame(self.canvas, bg="#12121F") self.scroll_frame.bind("<Configure>", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all"))) self.canvas.create_window((0, 0), window=self.scroll_frame, anchor="nw") self.canvas.configure(yscrollcommand=scrollbar.set) self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) # 鼠标滚轮 self.canvas.bind("<MouseWheel>", self._on_mousewheel) def_on_mousewheel(self, event): self.canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") def_create_widgets(self): """创建所有IO点位控件"""ifnotself.io_config: empty_label = tk.Label(self.scroll_frame, text="未找到IO配置信息", font=("微软雅黑", 12), fg="#7F8C8D", bg="#12121F") empty_label.pack(expand=True, pady=50) return# 按区域分组数据 zone_data = {} for addr, config inself.io_config.items(): zone = config.get("zone", "未分组") if zone notin zone_data: zone_data[zone] = [] zone_data[zone].append((addr, config)) # 创建区域管理器和控件 for zone_name, items in zone_data.items(): # 创建区域管理器 zone_section = ZoneSection(self.scroll_frame, zone_name) self.zones[zone_name] = zone_section # 创建本区域的IO控件 for addr, config in items: widget = IOPointWidget( self.scroll_frame, addr, config["desc"], config["type"], config["zone"] ) # 添加到区域管理器 zone_section.add_widget(addr, widget) # 添加到全局控件字典 self.widgets[addr] = widget # 默认显示 widget.pack(fill=tk.X, padx=8, pady=1) # 显示区域标题 zone_section.show() self._update_stats() def_on_entry_focus_in(self, event): ifself.search_entry.get() == self.placeholder: self.search_entry.delete(0, tk.END) self.search_entry.config(fg="white") def_on_entry_focus_out(self, event): ifnotself.search_entry.get(): self.search_entry.insert(0, self.placeholder) self.search_entry.config(fg="#7F8C8D") def_clear_search(self): self._search_var.set("") ifself.search_entry: self.search_entry.config(fg="white") self.search_entry.focus_set() def_on_search(self, *args): """🔧 修复后的搜索逻辑 - 保持区域分组"""ifnotself.stats_label ornotself.widgets: return keyword = self._search_var.get().strip().lower() if keyword == self.placeholder.lower(): keyword = "" total_visible = 0# 按区域过滤,每个区域单独处理 for zone_name, zone_section inself.zones.items(): visible_count = zone_section.filter_widgets(keyword) total_visible += visible_count self._update_stats(total_visible, len(self.widgets)) def_update_stats(self, visible=None, total=None): ifnotself.stats_label: returnif visible isNone: visible = len(self.widgets) if total isNone: total = len(self.widgets) if visible == total: text = f"共 {total} 个IO点位"else: text = f"显示 {visible}/{total} 个IO点位"self.stats_label.config(text=text) defupdate_states(self, states: dict): """批量更新IO状态"""for addr, state in states.items(): if addr inself.widgets: self.widgets[addr].set_state(state) classIOMonitorApp(tk.Tk): """IO监控主应用"""def__init__(self): super().__init__() self.title("IO点位搜索监控面板 v2.1") self.configure(bg="#0A0A0A") self.geometry("900x700") self.resizable(True, True) self.io_config = load_io_config("io_config.json") self.io_states = {addr: 0for addr inself.io_config} self._build_ui() self._start_simulation() def_build_ui(self): # 标题栏 header = tk.Frame(self, bg="#0D0D1A", height=50) header.pack(fill=tk.X) header.pack_propagate(False) title_label = tk.Label(header, text="⚡ IO点位实时监控系统", font=("微软雅黑", 14, "bold"), fg="#F39C12", bg="#0D0D1A") title_label.pack(side=tk.LEFT, padx=16, pady=12) self.status_label = tk.Label(header, text="● 运行中", font=("微软雅黑", 10), fg="#2ECC71", bg="#0D0D1A") self.status_label.pack(side=tk.RIGHT, padx=16) # IO面板 self.io_panel = SearchableIOPanel(self, self.io_config) self.io_panel.pack(fill=tk.BOTH, expand=True) # 状态栏 statusbar = tk.Frame(self, bg="#0D0D1A", height=30) statusbar.pack(fill=tk.X, side=tk.BOTTOM) statusbar.pack_propagate(False) self.bottom_stats = tk.Label(statusbar, text="", font=("Consolas", 9), fg="#95A5A6", bg="#0D0D1A") self.bottom_stats.pack(side=tk.LEFT, padx=12, pady=6) def_start_simulation(self): defsimulate(): whileTrue: try: for addr inlist(self.io_states.keys()): if random.random() < 0.1: self.io_states[addr] = 1 - self.io_states[addr] self.after(0, self._update_ui) time.sleep(0.8) except Exception as e: print(f"[模拟线程错误] {e}") break thread = threading.Thread(target=simulate, daemon=True) thread.start() def_update_ui(self): try: self.io_panel.update_states(self.io_states) active_count = sum(self.io_states.values()) total_count = len(self.io_states) self.bottom_stats.config( text=f"激活: {active_count} | "f"总数: {total_count} | "f"更新时间: {time.strftime('%H:%M:%S')}" ) except Exception as e: print(f"[UI更新错误] {e}") defmain(): try: app = IOMonitorApp() # 快捷键 app.bind("<Control-f>", lambda e: app.io_panel.search_entry.focus_set() if app.io_panel.search_entry elseNone) app.bind("<Escape>", lambda e: app.io_panel._clear_search()) app.mainloop() except Exception as e: print(f"[程序启动错误] {e}") input("按回车键退出...") if __name__ == "__main__": main()
这一版扩展性强多了。JSON配置文件可以由运维人员维护,开发和配置彻底解耦。我在一个汽车焊装线项目里用过类似思路,300多个IO点位,现场工程师自己就能改配置、加描述,完全不需要找我。
前两个方案都是"模拟数据",真正上生产,得接真实协议。这里给出Modbus TCP的接入骨架,用pymodbus库:
# 安装依赖:pip install pymodbusfrom pymodbus.client import ModbusTcpClientimport threading, timeclassModbusIOReader:"""Modbus TCP IO读取器,线程安全设计"""def__init__(self, host: str, port: int = 502):self.client = ModbusTcpClient(host, port=port)self._state: dict[str, int] = {}self._lock = threading.Lock()self._running = Falsedefconnect(self) -> bool:returnself.client.connect()defstart_polling(self, interval: float = 0.5):"""启动后台轮询"""self._running = True t = threading.Thread(target=self._poll_loop, args=(interval,), daemon=True) t.start()def_poll_loop(self, interval: float):whileself._running:try:# 读线圈(DI/DO):地址0起,读16个点 result = self.client.read_coils(0, 16, slave=1)ifnot result.isError():withself._lock:for i, bit inenumerate(result.bits[:16]):self._state[f"DI_{i:04d}"] = int(bit)except Exception as e:print(f"[Modbus] 读取异常: {e}") time.sleep(interval)defget_state(self) -> dict:"""线程安全地获取当前状态快照"""withself._lock:returndict(self._state)defstop(self):self._running = Falseself.client.close()# 用法示意(接入前面的IOPanelApp):# reader = ModbusIOReader("192.168.1.100")# if reader.connect():# reader.start_polling(0.5)# 然后在_refresh_ui里改成:io_state.update(reader.get_state())💡 扩展建议:除了Modbus,工控现场还常见OPC-UA(用
opcua库)、MQTT(用paho-mqtt)、西门子S7协议(用python-snap7)。接入方式大同小异,把ModbusIOReader这层换掉就行,上层界面完全不用动。这就是分层设计的好处。
① 映射先行:IO可视化的核心不是界面有多花,而是地址→语义的映射建得有没有。
② after()是命根:Tkinter多线程,后台算数据,
after(0, callback)回主线程改界面,这条规矩不能破。③ 分组分层:点位按功能区域分组,逻辑和界面分层,后期维护才不抓狂。
如果你想在这条路上走得更远,推荐这个顺序:
留两个话题,欢迎评论区聊:
文章干货不少,如果对你有用,转发给身边做工控上位机的朋友——这种资料找起来真的费劲,直接转省很多事。也欢迎关注,后续还有Tkinter工控专项的更多实战内容持续更新。