做过工控项目的朋友都懂——凌晨两点,车间里几十路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画出状态指示灯,跑通整个流程。
pythonimport tkinter as tk
from tkinter import ttk
import random
import threading
import 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: 0 for addr in IO_MAP}
class IOPointWidget(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 = addr
self.io_type = io_type
self._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)
def set_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)
class IOPanelApp(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] = widget
def _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状态变化"""
def worker():
while True:
# 真实项目里,这里换成读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 = 0
for addr, widget in self._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,还加了搜索框,现场查点位贼方便。
pythonimport json
import tkinter as tk
from tkinter import ttk
import random
import threading
import time
import os
def create_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"},
}
with open("io_config.json", "w", encoding="utf-8") as f:
json.dump(sample_config, f, ensure_ascii=False, indent=2)
def load_io_config(path: str) -> dict:
"""从JSON加载IO配置,容错处理"""
try:
with open(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 {}
class IOPointWidget(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 = 0
self.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" if self.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"))
def set_state(self, state: int):
"""更新点位状态"""
if state == self._state:
return
self._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)
class ZoneSection:
"""区域分组容器,管理区域标题和点位"""
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 = False
def add_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} 个点位")
def show(self):
"""显示区域(标题和分割线)"""
if not self.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 = True
def hide(self):
"""隐藏区域"""
if self.is_visible:
self.zone_frame.pack_forget()
self.separator.pack_forget()
self.is_visible = False
def filter_widgets(self, keyword: str) -> int:
"""根据关键词过滤本区域的控件,返回可见数量"""
visible_count = 0
has_visible = False
for addr, widget in self.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 = True
else:
widget.pack_forget()
# 根据是否有可见控件决定显示/隐藏区域标题
if has_visible:
self.show()
else:
self.hide()
return visible_count
class SearchableIOPanel(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 = None
self.search_entry = None
self._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点位控件"""
if not self.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 in self.io_config.items():
zone = config.get("zone", "未分组")
if zone not in 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):
if self.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):
if not self.search_entry.get():
self.search_entry.insert(0, self.placeholder)
self.search_entry.config(fg="#7F8C8D")
def _clear_search(self):
self._search_var.set("")
if self.search_entry:
self.search_entry.config(fg="white")
self.search_entry.focus_set()
def _on_search(self, *args):
"""🔧 修复后的搜索逻辑 - 保持区域分组"""
if not self.stats_label or not self.widgets:
return
keyword = self._search_var.get().strip().lower()
if keyword == self.placeholder.lower():
keyword = ""
total_visible = 0
# 按区域过滤,每个区域单独处理
for zone_name, zone_section in self.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):
if not self.stats_label:
return
if visible is None:
visible = len(self.widgets)
if total is None:
total = len(self.widgets)
if visible == total:
text = f"共 {total} 个IO点位"
else:
text = f"显示 {visible}/{total} 个IO点位"
self.stats_label.config(text=text)
def update_states(self, states: dict):
"""批量更新IO状态"""
for addr, state in states.items():
if addr in self.widgets:
self.widgets[addr].set_state(state)
class IOMonitorApp(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: 0 for addr in self.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):
def simulate():
while True:
try:
for addr in list(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}")
def main():
try:
app = IOMonitorApp()
# 快捷键
app.bind("<Control-f>", lambda e: app.io_panel.search_entry.focus_set() if app.io_panel.search_entry else None)
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库:
python# 安装依赖:pip install pymodbus
from pymodbus.client import ModbusTcpClient
import threading, time
class ModbusIOReader:
"""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 = False
def connect(self) -> bool:
return self.client.connect()
def start_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):
while self._running:
try:
# 读线圈(DI/DO):地址0起,读16个点
result = self.client.read_coils(0, 16, slave=1)
if not result.isError():
with self._lock:
for i, bit in enumerate(result.bits[:16]):
self._state[f"DI_{i:04d}"] = int(bit)
except Exception as e:
print(f"[Modbus] 读取异常: {e}")
time.sleep(interval)
def get_state(self) -> dict:
"""线程安全地获取当前状态快照"""
with self._lock:
return dict(self._state)
def stop(self):
self._running = False
self.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)回主线程改界面,这条规矩不能破。③ 分组分层:点位按功能区域分组,逻辑和界面分层,后期维护才不抓狂。
如果你想在这条路上走得更远,推荐这个顺序:
留两个话题,欢迎评论区聊:
你的项目IO点位大概有多少个?用什么协议接入的? 西门子、欧姆龙、汇川,各家有各家的坑,大家互通有无。
小挑战:试着改造方案一,给每个IO点位加一个"点击切换模拟状态"的功能(仅用于调试模式)。有思路了可以在评论区分享代码片段。
文章干货不少,如果对你有用,转发给身边做工控上位机的朋友——这种资料找起来真的费劲,直接转省很多事。也欢迎关注,后续还有Tkinter工控专项的更多实战内容持续更新。
#Python工控开发 #Tkinter实战 #IO点位监控 #上位机开发 #工业自动化
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!