python
复制代码
# -*- coding: utf-8 -*-
"""
Alarm Monitor -- 读取 PLC 数据并在 UI 中显示对应的 AlarmMessage
"""
import os
import time
import csv
import threading
import tkinter as tk
from tkinter import ttk, messagebox, simpledialog
from typing import List, Dict
# ====================== 配置 ======================
CSV_ALARM = r"Alarm_Code_V1.csv"
CSV_LOG = "4_CLN_log.csv"
REAL_ALARM_TXT = r"Real_Alarm.txt"
DEFAULT_ADDRESSES = ["D30000", "D30031", "D30000", "D30031", "D30000", "D30031"]
PLC_IP = "200.200.200.200"
PLC_PORT = 5000
Scan_Time_Second = 1 # 默认轮询间隔(秒)
# ====================== 辅助函数 ======================
def load_alarm_dict(csv_path: str) -> Dict[str, str]:
"""读取 Alarm_Code_V1.csv,返回 id → alarm_message 的映射。"""
ID_COL = "id"
MSG_COL = "alarmmessage"
if not os.path.exists(csv_path):
print(f"[WARN] Alarm CSV not found: {csv_path}")
return {}
for enc in ("utf-8-sig", "gbk", "gb18030"):
try:
with open(csv_path, newline="", encoding=enc) as f:
rdr = csv.DictReader(f)
if ID_COL not in rdr.fieldnames or MSG_COL not in rdr.fieldnames:
raise ValueError(
f"CSV 必须包含 '{ID_COL}' 与 '{MSG_COL}' 列,当前列名为 {rdr.fieldnames}"
)
mapping = {row[ID_COL].strip(): row[MSG_COL].strip() for row in rdr}
print(f"[INFO] Loaded {len(mapping)} alarm records from {csv_path}")
return mapping
except UnicodeDecodeError:
continue
except Exception as e:
print(f"[ERROR] 读取 {csv_path} 时出错(编码 {enc}):{e}")
continue
# 修改点:增加更详细的错误提示
error_msg = f"无法使用 utf-8-sig、gbk、gb18030 读取文件 {csv_path},请检查文件是否存在且编码正确"
print(f"[ERROR] {error_msg}")
messagebox.showerror("CSV读取错误", error_msg)
return {}
def save_plc_config(ip: str, port: int, scan_time: int):
"""保存 PLC 参数到全局变量。"""
global PLC_IP, PLC_PORT, Scan_Time_Second
PLC_IP, PLC_PORT, Scan_Time_Second = ip, port, scan_time
# ====================== PLC 读取类 ======================
class PLCReader:
"""封装 PLC 读取、CSV 写入以及地址管理"""
def __init__(self, ip: str, port: int, init_addresses: List[str] = None):
self.ip = ip
self.port = port
# 假设 MelsecMcNet 是来自某个库,这里添加导入
try:
from HslCommunication import MelsecMcNet
self.MelsecMcNet = MelsecMcNet
except ImportError:
# 如果导入失败,则创建一个模拟类以便程序可以运行
class MockMelsecMcNet:
def __init__(self, ip, port):
pass
def ConnectServer(self):
class MockResult:
IsSuccess = True
Message = "Mock connection successful"
def __init__(self):
self.IsSuccess = True
self.Message = "Mock connection successful"
return MockResult()
def ConnectClose(self):
pass
def ReadInt16(self, address, count):
class MockResult:
IsSuccess = True
Content = [0] * count
Message = "Mock read successful"
def __init__(self):
self.IsSuccess = True
self.Content = [0] * count
self.Message = "Mock read successful"
return MockResult()
self.MelsecMcNet = MockMelsecMcNet
self.mc = self.MelsecMcNet(ip, port)
self.connected = False
self.addresses: List[str] = init_addresses or []
self.value_cache: Dict[str, int] = {}
# ---------- 连接 ----------
def connect(self) -> bool:
conn = self.mc.ConnectServer()
self.connected = conn.IsSuccess
if not self.connected:
print(f"[ERROR] Connect NG:{conn.Message}")
return self.connected
def close(self):
if self.connected:
self.mc.ConnectClose()
self.connected = False
def reconnect(self, ip: str, port: int) -> bool:
self.close()
self.ip, self.port = ip, port
self.mc = MelsecMcNet(ip, port)
return self.connect()
# ---------- 地址管理 ----------
def add_address(self, addr: str):
addr = self._normalize_address(addr)
if not addr.startswith('D'):
raise ValueError("Only support D address, e.g. D30000")
if addr not in self.addresses:
self.addresses.append(addr)
self.value_cache[addr] = None
def _normalize_address(self, addr: str) -> str:
if not addr:
return ""
clean = addr.strip().upper()
if clean.startswith('DM'):
return 'D' + clean[2:]
if clean.startswith('W'):
return 'D' + clean[1:]
return clean
# ---------- 读取 ----------
def read_all(self) -> List[int]:
if not self.connected:
raise RuntimeError("PLC is not connected")
vals = []
for a in self.addresses:
r = self.mc.ReadInt16(a, 1)
if not r.IsSuccess:
raise RuntimeError(f"{a} Read NG:{r.Message}")
vals.append(r.Content[0])
return vals
# ---------- 写入 CSV ----------
def write_csv(self, timestamp: str, values: List[int], user_texts: List[str]):
with open(CSV_LOG, "a", newline="", encoding="utf-8-sig") as f:
w = csv.writer(f)
for a, v, txt in zip(self.addresses, values, user_texts):
w.writerow([timestamp, a, v, txt])
# ---------- 写入实时报警文件 ----------
def write_real_alarm(self, values: List[int]):
ts = time.strftime("%Y-%m-%d %H:%M:%S")
lines = [f"{ts} : {addr} = {val}" for addr, val in zip(self.addresses, values)]
with open(REAL_ALARM_TXT, "w", encoding="utf-8-sig") as f:
f.write("\n".join(lines))
# ====================== 主界面 ======================
class App(tk.Tk):
def __init__(self, plc_reader: PLCReader, alarm_dict: Dict[str, str]):
super().__init__()
self.title("Design_By_Tim")
self.geometry("750x680")
self.resizable(True, True)
self.plc = plc_reader
self.alarm_dict = alarm_dict
self.id_prefix = "" # 如需前缀可改为 "ALM_"
# ---------- 状态栏 ----------
self.status_var = tk.StringVar(value="Int...")
ttk.Label(self, textvariable=self.status_var, anchor="w") \
.grid(row=0, column=0, columnspan=8, sticky="ew", padx=10, pady=5)
# ---------- PLC 参数 ----------
param_frame = ttk.LabelFrame(self, text="PLC Setting")
param_frame.grid(row=1, column=0, columnspan=8, sticky="ew", padx=10, pady=5)
ttk.Label(param_frame, text="IP:").grid(row=0, column=0, padx=5, pady=2)
self.ip_entry = ttk.Entry(param_frame, width=15)
self.ip_entry.insert(0, PLC_IP)
self.ip_entry.grid(row=0, column=1, padx=5, pady=2)
ttk.Label(param_frame, text="Port:").grid(row=0, column=2, padx=5, pady=2)
self.port_entry = ttk.Entry(param_frame, width=7)
self.port_entry.insert(0, str(PLC_PORT))
self.port_entry.grid(row=0, column=3, padx=5, pady=2)
ttk.Label(param_frame, text="Scan_Time (Second):").grid(row=0, column=4, padx=5, pady=2)
self.scan_entry = ttk.Entry(param_frame, width=5)
self.scan_entry.insert(0, str(Scan_Time_Second))
self.scan_entry.grid(row=0, column=5, padx=5, pady=2)
ttk.Button(param_frame, text="Save Parameter", command=self.save_plc_config) \
.grid(row=0, column=6, padx=8)
ttk.Button(param_frame, text="Connect PLC", command=self.manual_connect) \
.grid(row=0, column=7, padx=8)
# ---------- 主表 ----------
main_frame = ttk.Frame(self)
main_frame.grid(row=2, column=0, columnspan=8, sticky="nsew", padx=10, pady=5)
self.grid_rowconfigure(2, weight=1)
self.grid_columnconfigure(0, weight=1)
self.tree = ttk.Treeview(
main_frame,
columns=("addr", "user_text", "int_val", "float_val", "char_str", "alarm_msg"),
show="headings",
height=25
)
self.tree.heading("addr", text="Address")
self.tree.heading("user_text", text="Text By User")
self.tree.heading("int_val", text="Int16")
self.tree.heading("float_val", text="Float")
self.tree.heading("char_str", text="String")
self.tree.heading("alarm_msg", text="AlarmMessage")
self.tree.column("addr", width=100, anchor="center")
self.tree.column("user_text", width=180, anchor="w")
self.tree.column("int_val", width=80, anchor="center")
self.tree.column("float_val", width=100, anchor="center")
self.tree.column("char_str", width=100, anchor="center")
self.tree.column("alarm_msg", width=200, anchor="w")
self.tree.grid(row=0, column=0, sticky="nsew")
v_scroll = ttk.Scrollbar(main_frame, orient="vertical", command=self.tree.yview)
v_scroll.grid(row=0, column=1, sticky="ns")
self.tree.configure(yscrollcommand=v_scroll.set)
# ---------- 双击编辑 ----------
self.tree.bind("<Double-1>", self._on_double_click)
# ---------- 控制按钮 ----------
btn_frame = ttk.Frame(self)
btn_frame.grid(row=3, column=0, columnspan=8, pady=8)
self.start_btn = ttk.Button(btn_frame, text="Start", command=self.start_polling)
self.start_btn.grid(row=0, column=0, padx=5)
self.stop_btn = ttk.Button(btn_frame, text="Stop", command=self.stop_polling, state="disabled")
self.stop_btn.grid(row=0, column=1, padx=5)
self.add_btn = ttk.Button(btn_frame, text="+", width=3, command=self.add_address_dialog)
self.add_btn.grid(row=0, column=2, padx=5)
self.del_btn = ttk.Button(btn_frame, text="--", width=3, command=self.delete_selected)
self.del_btn.grid(row=0, column=3, padx=5)
# ---------- 初始化默认地址 ----------
for addr in self.plc.addresses:
alarm_msg = self._calc_alarm_message(addr, 0, "")
self.tree.insert("", tk.END,
values=(addr.upper(), "", "", "", "", alarm_msg))
self._polling = False
self.scan_time = Scan_Time_Second
# ---------- 绑定 ----------
self.bind("<F1>", lambda e: self.show_help())
self.protocol("WM_DELETE_WINDOW", self.on_close)
# ---------- 报警文字计算 (修改后的逻辑) ----------
def _calc_alarm_message(self, addr: str, int_val: int, str_val: str) -> str:
"""
三条规则:
1. 若 String(str_val)非空 → 盘直接返回该字符串;
2. 若 String 为空且 int_val != 0 → 从 CSV(第6列)查找对应描述;
3. 其余 → 返回 "当前没有报警"。
"""
# 规则 1:字符串不为空,直接返回去除首尾空格后的内容
if str_val.strip():
return str_val.strip()
# 规则 2:字符串为空且 int_val 不为 0
if int_val != 0:
# 使用 int_val 作为 id 查找报警信息
if str(int_val) in self.alarm_dict:
return self.alarm_dict[str(int_val)]
else:
# 修改点:提供更明确的错误信息,显示实际的INT16值而不是地址
return f"未找到ID {int_val} 对应的报警信息,当前值为 {int_val}"
# 规则 3:其余情况(字符串为空且 int_val 为 0)
return "当前没有报警"
# ---------- 双击编辑 (修复后的逻辑) ----------
def _on_double_click(self, event):
region = self.tree.identify("region", event.x, event.y)
if region != "cell":
return
# 修复点 1: 补全赋值操作符 '='
col = self.tree.identify_column(event.x)
if col != "#2": # 只允许编辑 "Text By User" 列
return
row_id = self.tree.identify_row(event.y)
if not row_id:
return
x, y, width, height = self.tree.bbox(row_id, column=col)
cur_text = self.tree.set(row_id, "user_text")
entry = ttk.Entry(self.tree)
entry.place(x=x, y=y, width=width, height=height)
entry.insert(0, cur_text)
entry.focus_set()
def save_edit(event=None):
new_val = entry.get()
values = list(self.tree.item(row_id, "values"))
values[1] = new_val
self.tree.item(row_id, values=values)
entry.destroy()
entry.bind("<Return>", save_edit)
entry.bind("<FocusOut>", save_edit)
# ---------- 保存 PLC 参数 ----------
def save_plc_config(self):
ip = self.ip_entry.get().strip()
try:
port = int(self.port_entry.get().strip())
except ValueError:
messagebox.showerror("Error", "Port Must be INT")
return
try:
scan_time = int(self.scan_entry.get().strip())
if scan_time <= 0:
raise ValueError
except ValueError:
messagebox.showerror("Error", "Scan_Time_Second must be positive INT")
return
save_plc_config(ip, port, scan_time)
self.scan_time = scan_time
if self.plc.connected:
if self.plc.reconnect(ip, port):
self.status_var.set("PLC Parameter Change, Reconnect")
else:
self.status_var.set("PLC Parameter Change, Reconnect NG")
else:
self.status_var.set("PLC Parameter Modified, Await Connect")
# ---------- 手动连接 ----------
def manual_connect(self):
if self.plc.connected:
messagebox.showinfo("Connect", "PLC Connect OK")
return
if self.plc.connect():
self.status_var.set("Connect OK")
else:
messagebox.showerror("Connect NG", "Check IP/Port/PLC hardware")
# ---------- 添加地址 ----------
def add_address_dialog(self):
addr = simpledialog.askstring("Add Address",
"Pls Input PLC Address(Eg: D30000)",
parent=self)
if not addr:
return
try:
norm = self.plc._normalize_address(addr)
self.plc.add_address(norm)
alarm_msg = self._calc_alarm_message(norm, 0, "")
self.tree.insert("", tk.END,
values=(norm.upper(), "", "", "", "", alarm_msg))
self.status_var.set(f"Finished to Add Address {norm.upper()}")
except Exception as e:
messagebox.showerror("Address Error", str(e))
# ---------- 删除选中 ----------
def delete_selected(self):
sel = self.tree.selection()
if not sel:
messagebox.showinfo("Del", "Pls Select 1st")
return
for item in sel:
addr = self.tree.item(item, "values")[0]
if addr in self.plc.addresses:
self.plc.addresses.remove(addr)
self.plc.value_cache.pop(addr, None)
self.tree.delete(item)
self.status_var.set("Del OK")
# ---------- 帮助 ----------
def show_help(self):
help_text = (
"使用说明(按 F1 随时查看)\n"
"----------------------------------------\n"
"1. 添加监控地址 → 点击左下角的 "+"。\n"
"2. 删除地址 → 选中表格中的行后点击 "--"。\n"
"3. 开始轮询 → 点击 "Start"。每 <扫描间隔> 秒读取一次 PLC 数据。\n"
"4. 停止轮询 → 点击 "Stop"。\n"
"5. 自定义文本 → 双击 "Text By User" 列,可直接编辑,内容会同步写入日志文件。\n"
"6. CSV 记录 → 每轮写入 `4_CLN_log.csv`,列为:时间、地址、数值、用户文本。\n"
"7. PLC 参数 → 在顶部编辑 IP/Port/扫描间隔,点 "Save Parameter"。\n"
"8. 手动连接 → 点 "Connect PLC"。\n"
"9. 报警信息 → 读取 Alarm_Code_V1.csv,右侧显示对应的 AlarmMessage(遵循三条规则)。\n"
"10. 退出 → 关闭窗口或使用快捷键确认退出。\n"
"温馨提示:仅支持 D 开头的寄存器地址。"
)
win = tk.Toplevel(self)
win.title("Remark")
win.geometry("560x460")
txt = tk.Text(win, wrap="word", padx=10, pady=10,
background="#f9f9f9")
txt.insert("1.0", help_text)
txt.configure(state="disabled")
txt.pack(expand=True, fill="both")
sb = ttk.Scrollbar(win, command=txt.yview)
sb.pack(side="right", fill="y")
txt.configure(yscrollcommand=sb.set)
# ---------- 轮询 ----------
def start_polling(self):
if not self.plc.connected:
if not self.plc.connect():
messagebox.showerror("Connect NG", "Can not Connect PLC,Check IP/Port.")
return
if not self.plc.addresses:
messagebox.showwarning("No Address", "Add Address 1st.")
return
self._polling = True
self.start_btn.config(state="disabled")
self.stop_btn.config(state="normal")
self.status_var.set("Connect OK,Start...")
self._schedule_poll()
def stop_polling(self):
self._polling = False
self.start_btn.config(state="normal")
self.stop_btn.config(state="disabled")
self.status_var.set("Stop...")
def _schedule_poll(self):
if self._polling:
threading.Thread(target=self.poll_once, daemon=True).start()
self.after(self.scan_time * 1000, self._schedule_poll)
def poll_once(self):
try:
values = self.plc.read_all()
ts = time.strftime("%Y-%m-%d %H:%M:%S")
user_texts = [self.tree.set(item, "user_text")
for item in self.tree.get_children()]
self.plc.write_csv(ts, values, user_texts)
self.plc.write_real_alarm(values) # 实时文件
print(f"\n=== {ts} ===")
for a, v, txt in zip(self.plc.addresses, values, user_texts):
print(f"{a} = {v} Remark:{txt}")
self.after(0, self.update_tree, ts, values)
except Exception as e:
self.after(0, self.status_var.set, f"Read Error:{e}")
# ---------- 更新 UI ----------
def update_tree(self, timestamp: str, values: List[int]):
"""只更新发生变化的行,保留已有的自定义文本和报警信息"""
max_len = min(len(self.tree.get_children()), len(self.plc.addresses))
for i in range(max_len):
item = self.tree.get_children()[i]
addr = self.plc.addresses[i]
new_val = values[i]
cur = self.tree.item(item, "values")
# 若数值未变,仅检查 AlarmMessage 是否需要刷新(String 可能被手动改动)
if cur[2] == str(new_val):
alarm_msg = self._calc_alarm_message(addr, new_val, cur[4])
if cur[5] != alarm_msg:
self.tree.item(item, values=(
cur[0], cur[1], cur[2], cur[3], cur[4], alarm_msg))
continue
# 数值变化时重新计算全部列
float_val = float(new_val)
try:
char_str = (chr(new_val) if 0 <= new_val <= 0x10FFFF
and chr(new_val).isprintable() else "")
except ValueError:
char_str = ""
alarm_msg = self._calc_alarm_message(addr, new_val, char_str)
self.tree.item(
item,
values=(
addr,
cur[1], # 保留自定义文本
new_val,
f"{float_val:.3f}",
char_str,
alarm_msg
)
)
self.status_var.set(f"{timestamp} Update")
# ---------- 退出 ----------
def on_close(self):
if messagebox.askokcancel("Exit", "Are U Sure?"):
self.stop_polling()
self.plc.close()
self.destroy()
# ====================== 程序入口 ======================
if __name__ == "__main__":
# 若日志文件不存在则创建表头
if not os.path.exists(CSV_LOG):
with open(CSV_LOG, "w", newline="", encoding="utf-8-sig") as f:
csv.writer(f).writerow(['Timestamp', 'Address', 'Value', 'Remark'])
# 修改点:增加异常处理,防止CSV读取错误导致程序崩溃
try:
alarm_mapping = load_alarm_dict(CSV_ALARM)
except Exception as e:
error_msg = f"加载报警配置文件时发生错误: {str(e)}"
print(f"[ERROR] {error_msg}")
messagebox.showerror("初始化错误", error_msg)
alarm_mapping = {} # 使用空字典继续运行而不是中断程序
plc_reader = PLCReader(PLC_IP, PLC_PORT, init_addresses=DEFAULT_ADDRESSES)
app = App(plc_reader, alarm_mapping)
app.mainloop()