PLC监控系统+UI Alarm Show

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()
相关推荐
爱笑的眼睛114 小时前
SQLAlchemy 核心 API 深度解析:超越 ORM 的数据库工具包
java·人工智能·python·ai
CoolScript4 小时前
WingIDE破解代码-支持最新版本
python
测试19984 小时前
Selenium(Python web测试工具)基本用法详解
自动化测试·软件测试·python·selenium·测试工具·职场和发展·测试用例
范小多4 小时前
24小时学会Python Visual code +Python Playwright通过谷歌浏览器取控件元素(连载、十一)
服务器·前端·python
曹牧4 小时前
Java:Foreach语法糖
java·开发语言·python
盼哥PyAI实验室4 小时前
Python验证码处理实战:从12306项目看验证码识别的技术演进
开发语言·网络·python
qq_356196954 小时前
day37简单的神经网络@浙大疏锦行
python
Lululaurel4 小时前
AI编程提示词工程实战指南:从入门到精通
人工智能·python·机器学习·ai·ai编程
winfredzhang4 小时前
Python桌面应用开发:浏览器录制与视频合并工具详解
python·音视频·浏览器·视频合并·视频录制·视频预览