#!/usr/bin/env python3 """ DBN MQTT Tool — DLD960 IoT MQTT 设备管理工具 跨平台桌面软件 (tkinter) """ import sys import os import json import time import tkinter as tk from tkinter import ttk, messagebox, scrolledtext from threading import Thread from datetime import datetime from typing import Optional # 确保项目路径可导入 sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from dbn_mqtt_tool.mqtt_client import MqttClient, BrokerConfig from dbn_mqtt_tool.device_manager import DeviceManager, DeviceInfo from dbn_mqtt_tool.protocol import ( CMD_DEV_INFO_QUERY, CMD_SSC_NET_QUERY, CMD_IOT_NET_QUERY, CMD_IOT_TOPIC_QUERY, CMD_LOOP_PARAM_QUERY, CMD_SSC_NET_SET, CMD_IOT_NET_SET, CMD_IOT_TOPIC_SET, CMD_LOOP_PARAM_SET, CMD_REPORT_CONFIG, CMD_PWD_VERIFY, CMD_PWD_SET, CMD_FACTORY_RESET, CMD_DEVICE_RESET, CMD_DEV_SERIAL_SET, CMD_LOOP_DATA, CMD_EVENT_REPORT, CMD_HEARTBEAT, ERROR_MSGS, FREQ_LEVELS, OUTPUT_MODES, data_ssc_net_set, data_iot_net_set, data_iot_topic_set, data_loop_param_set, data_report_config, data_pwd_verify, data_pwd_set, data_dev_serial_set, topic_data_loop, topic_data_event, topic_status, ) class DBNMqttTool(tk.Tk): """主窗口""" def __init__(self): super().__init__() self.title("DBN MQTT Tool — DLD960 IoT 设备管理") self.geometry("1200x750") self.minsize(900, 600) self._mqtt = MqttClient() self._devmgr = DeviceManager() # MQTT 回调 self._mqtt.on_status_change(self._on_mqtt_status) self._mqtt.on_message(self._on_mqtt_message) # 设备列表变化 self._devmgr.on_change(self._refresh_device_list) self._pending: dict[int, str] = {} # msg_id → 命令名 self._log_lines: list[str] = [] self._build_ui() # ================================================================ # UI 构建 # ================================================================ def _build_ui(self): # -- 顶栏:连接 -- top_bar = ttk.Frame(self, padding=5) top_bar.pack(fill=tk.X) ttk.Label(top_bar, text="Broker:").pack(side=tk.LEFT, padx=(0, 5)) self._host_var = tk.StringVar(value="121.37.20.199") ttk.Entry(top_bar, textvariable=self._host_var, width=16).pack(side=tk.LEFT, padx=2) ttk.Label(top_bar, text="Port:").pack(side=tk.LEFT, padx=(5, 2)) self._port_var = tk.IntVar(value=1883) ttk.Entry(top_bar, textvariable=self._port_var, width=6).pack(side=tk.LEFT, padx=2) ttk.Label(top_bar, text="用户:").pack(side=tk.LEFT, padx=(5, 2)) self._user_var = tk.StringVar() ttk.Entry(top_bar, textvariable=self._user_var, width=10).pack(side=tk.LEFT, padx=2) ttk.Label(top_bar, text="密码:").pack(side=tk.LEFT, padx=(5, 2)) self._pass_var = tk.StringVar() ttk.Entry(top_bar, textvariable=self._pass_var, width=10, show="*").pack(side=tk.LEFT, padx=2) self._btn_connect = ttk.Button(top_bar, text="连接", command=self._toggle_connect) self._btn_connect.pack(side=tk.LEFT, padx=10) self._conn_status = ttk.Label(top_bar, text="● 未连接", foreground="red") self._conn_status.pack(side=tk.LEFT, padx=10) # -- 主体:左右分栏 -- main = ttk.PanedWindow(self, orient=tk.HORIZONTAL) main.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) # 左侧:设备列表 left = ttk.Frame(main, width=280) main.add(left, weight=0) ttk.Label(left, text="设备列表", font=("", 11, "bold")).pack(anchor=tk.W, pady=(0, 5)) self._dev_tree = ttk.Treeview(left, columns=("model",), show="tree headings", height=14) self._dev_tree.heading("#0", text="设备序列码") self._dev_tree.heading("model", text="型号") self._dev_tree.column("#0", width=140) self._dev_tree.column("model", width=80) self._dev_tree.pack(fill=tk.BOTH, expand=True) self._dev_tree.bind("<>", self._on_device_select) btn_frame = ttk.Frame(left) btn_frame.pack(fill=tk.X, pady=5) ttk.Button(btn_frame, text="查询设备信息", command=self._query_dev_info).pack(side=tk.LEFT, padx=2) ttk.Button(btn_frame, text="查询全部", command=self._query_all).pack(side=tk.LEFT, padx=2) # 右侧:标签页 right = ttk.Frame(main) main.add(right, weight=1) self._notebook = ttk.Notebook(right) self._notebook.pack(fill=tk.BOTH, expand=True) self._tab_info = self._add_info_tab() self._tab_config = self._add_config_tab() self._tab_data = self._add_data_tab() self._tab_log = self._add_log_tab() def _add_info_tab(self): f = ttk.Frame(self._notebook, padding=10) self._notebook.add(f, text="设备信息") self._info_text = scrolledtext.ScrolledText(f, height=18, width=60, state=tk.DISABLED, font=("Consolas", 10)) self._info_text.pack(fill=tk.BOTH, expand=True) return f def _add_config_tab(self): f = ttk.Frame(self._notebook, padding=10) self._notebook.add(f, text="参数配置") row = 0 # -- 网口配置 -- ttk.Label(f, text="▸ SSC 网络配置", font=("", 10, "bold")).grid(row=row, column=0, columnspan=4, sticky=tk.W, pady=(0, 5)) row += 1 for label, var_name, col in [ ("设备 IP", "ssc_ip", 0), ("子网掩码", "ssc_mask", 1), ("网关", "ssc_gw", 2), ("LSSC IP", "ssc_lssc", 3), ("DNS", "ssc_dns", 0), ("端口", "ssc_port", 1), ]: ttk.Label(f, text=label + ":").grid(row=row, column=col * 2, sticky=tk.E, padx=2, pady=1) var = tk.StringVar() setattr(self, f"_var_{var_name}", var) ttk.Entry(f, textvariable=var, width=15).grid(row=row, column=col * 2 + 1, sticky=tk.W, padx=2, pady=1) if col == 3 or (col == 1 and var_name == "ssc_port"): row += 1 ttk.Button(f, text="查询 SSC 网络", command=self._query_ssc_net).grid(row=row, column=0, pady=5, padx=2, sticky=tk.W) ttk.Button(f, text="设置 SSC 网络", command=self._set_ssc_net).grid(row=row, column=1, pady=5, padx=2, sticky=tk.W) row += 1 # -- IoT 配置 -- ttk.Separator(f, orient=tk.HORIZONTAL).grid(row=row, column=0, columnspan=4, sticky=tk.EW, pady=10) row += 1 ttk.Label(f, text="▸ IoT 网络配置", font=("", 10, "bold")).grid(row=row, column=0, columnspan=4, sticky=tk.W, pady=(0, 5)) row += 1 for label, var_name in [ ("MQTT Host", "iot_host"), ("MQTT Port", "iot_port"), ("Client ID", "iot_cid"), ("用户名", "iot_user"), ("密码", "iot_pass"), ]: ttk.Label(f, text=label + ":").grid(row=row, column=0, sticky=tk.E, padx=2, pady=1) var = tk.StringVar() setattr(self, f"_var_{var_name}", var) w = ttk.Entry(f, textvariable=var, width=30) if "pass" in var_name.lower(): w.configure(show="*") w.grid(row=row, column=1, columnspan=3, sticky=tk.W, padx=2, pady=1) row += 1 ttk.Button(f, text="查询 IoT 网络", command=self._query_iot_net).grid(row=row, column=0, pady=5, padx=2, sticky=tk.W) ttk.Button(f, text="设置 IoT 网络", command=self._set_iot_net).grid(row=row, column=1, pady=5, padx=2, sticky=tk.W) row += 1 # -- Topic 配置 -- ttk.Separator(f, orient=tk.HORIZONTAL).grid(row=row, column=0, columnspan=4, sticky=tk.EW, pady=10) row += 1 ttk.Label(f, text="▸ IoT Topic 配置", font=("", 10, "bold")).grid(row=row, column=0, columnspan=4, sticky=tk.W, pady=(0, 5)) row += 1 self._var_cid_enable = tk.BooleanVar(value=True) ttk.Checkbutton(f, text="Client ID 启用", variable=self._var_cid_enable).grid(row=row, column=0, columnspan=2, sticky=tk.W) row += 1 for label, var_name in [("Topic 发布", "topic_pub"), ("Topic 订阅", "topic_sub")]: ttk.Label(f, text=label + ":").grid(row=row, column=0, sticky=tk.E, padx=2, pady=1) var = tk.StringVar() setattr(self, f"_var_{var_name}", var) ttk.Entry(f, textvariable=var, width=40).grid(row=row, column=1, columnspan=3, sticky=tk.W, padx=2, pady=1) row += 1 ttk.Button(f, text="查询 Topic", command=self._query_iot_topic).grid(row=row, column=0, pady=5, padx=2, sticky=tk.W) ttk.Button(f, text="设置 Topic", command=self._set_iot_topic).grid(row=row, column=1, pady=5, padx=2, sticky=tk.W) row += 1 # -- 控制区 -- ttk.Separator(f, orient=tk.HORIZONTAL).grid(row=row, column=0, columnspan=4, sticky=tk.EW, pady=10) row += 1 ttk.Label(f, text="▸ 控制命令", font=("", 10, "bold")).grid(row=row, column=0, columnspan=4, sticky=tk.W, pady=(0, 5)) row += 1 self._var_pwd = tk.StringVar() ttk.Label(f, text="密码:").grid(row=row, column=0, sticky=tk.E, padx=2) ttk.Entry(f, textvariable=self._var_pwd, width=10, show="*").grid(row=row, column=1, sticky=tk.W, padx=2) self._var_new_pwd = tk.StringVar() ttk.Label(f, text="新密码:").grid(row=row, column=2, sticky=tk.E, padx=2) ttk.Entry(f, textvariable=self._var_new_pwd, width=10, show="*").grid(row=row, column=3, sticky=tk.W, padx=2) row += 1 ttk.Button(f, text="验证密码", command=self._do_pwd_verify).grid(row=row, column=0, pady=5, padx=2) ttk.Button(f, text="设置密码", command=self._do_pwd_set).grid(row=row, column=1, pady=5, padx=2) ttk.Button(f, text="出厂初始化", command=self._do_factory_reset).grid(row=row, column=2, pady=5, padx=2) ttk.Button(f, text="设备复位", command=self._do_device_reset).grid(row=row, column=3, pady=5, padx=2) return f def _add_data_tab(self): f = ttk.Frame(self._notebook, padding=10) self._notebook.add(f, text="实时数据") # 线圈数据 ttk.Label(f, text="▸ 线圈传感数据 (loop_data)", font=("", 10, "bold")).pack(anchor=tk.W) self._loop_text = scrolledtext.ScrolledText(f, height=10, width=80, state=tk.DISABLED, font=("Consolas", 10)) self._loop_text.pack(fill=tk.BOTH, expand=True, pady=5) # 事件 ttk.Label(f, text="▸ 事件上报 (event_report)", font=("", 10, "bold")).pack(anchor=tk.W) self._event_text = scrolledtext.ScrolledText(f, height=6, width=80, state=tk.DISABLED, font=("Consolas", 10)) self._event_text.pack(fill=tk.BOTH, expand=True, pady=5) return f def _add_log_tab(self): f = ttk.Frame(self._notebook, padding=10) self._notebook.add(f, text="日志") self._log_text = scrolledtext.ScrolledText(f, height=18, width=60, state=tk.DISABLED, font=("Consolas", 9)) self._log_text.pack(fill=tk.BOTH, expand=True) ttk.Button(f, text="清空", command=self._clear_log).pack(anchor=tk.E, pady=5) return f # ================================================================ # MQTT 操作 # ================================================================ def _toggle_connect(self): if self._mqtt.connected: self._mqtt.disconnect() else: config = BrokerConfig( host=self._host_var.get(), port=self._port_var.get(), username=self._user_var.get(), password=self._pass_var.get(), ) self._mqtt.connect(config) self._log(f"正在连接 {config.host}:{config.port} ...") def _on_mqtt_status(self, connected: bool, msg: str): self.after(0, lambda: self._update_conn_status(connected, msg)) def _update_conn_status(self, connected: bool, msg: str): if connected: self._btn_connect.configure(text="断开") self._conn_status.configure(text="● 已连接", foreground="green") else: self._btn_connect.configure(text="连接") self._conn_status.configure(text="● 未连接", foreground="red") self._log(msg) def _on_mqtt_message(self, topic: str, dev_serial: str, payload: dict): self.after(0, lambda: self._handle_message(topic, dev_serial, payload)) def _handle_message(self, topic: str, dev_serial: str, payload: dict): cmd = payload.get("cmd", "") ts = datetime.fromtimestamp(payload.get("ts", 0)).strftime("%H:%M:%S") if "/dev/config/resp" in topic: code = payload.get("code", -1) pmsg = payload.get("msg", "") data = payload.get("data", {}) self._log(f"[{ts}] {dev_serial} ← {cmd} code={code} {pmsg}") if code == 0: if cmd == CMD_DEV_INFO_QUERY: self._devmgr.update_from_dev_info(dev_serial, data) self._show_info(data) elif cmd in (CMD_SSC_NET_QUERY, CMD_IOT_NET_QUERY, CMD_IOT_TOPIC_QUERY): self._show_info({cmd: data}) elif cmd == CMD_LOOP_PARAM_QUERY: self._show_info({cmd: data}) else: self._show_info({"error": f"code={code} {pmsg} ({ERROR_MSGS.get(code, '?')})"}) elif "/dev/data/loop" in topic: data = payload.get("data", {}) self._devmgr.update_loop_data(dev_serial, data) self._log(f"[{ts}] {dev_serial} ← loop_data ({len(data.get('channels', []))} 通道)") self._append_data(self._loop_text, json.dumps(payload, indent=2, ensure_ascii=False)) elif "/dev/data/event" in topic: data = payload.get("data", {}) self._devmgr.update_event(dev_serial, data) events = data.get("events", []) self._log(f"[{ts}] {dev_serial} ← event_report ({len(events)} 事件)") self._append_data(self._event_text, json.dumps(payload, indent=2, ensure_ascii=False)) elif "/dev/status" in topic: data = payload.get("data", {}) self._devmgr.update_heartbeat(dev_serial, data) self._log(f"[{ts}] {dev_serial} ← heartbeat (uptime={data.get('uptime', 0)}s)") # ================================================================ # 设备操作 # ================================================================ def _selected_dev(self) -> Optional[str]: sel = self._dev_tree.selection() if sel: return self._dev_tree.item(sel[0], "text") return None def _require_dev(self) -> Optional[str]: sn = self._selected_dev() if not sn: messagebox.showwarning("提示", "请先在左侧选择一个设备") return None if not self._mqtt.connected: messagebox.showwarning("提示", "请先连接 MQTT Broker") return None return sn def _send_cmd(self, cmd: str, data: Optional[dict] = None): sn = self._require_dev() if not sn: return try: msg_id = self._mqtt.send_command(sn, cmd, data) self._log(f"→ {sn} {cmd} (msg_id={msg_id})") except Exception as e: messagebox.showerror("发送失败", str(e)) def _query_dev_info(self): self._send_cmd(CMD_DEV_INFO_QUERY) def _query_all(self): sn = self._require_dev() if not sn: return for cmd in [CMD_DEV_INFO_QUERY, CMD_SSC_NET_QUERY, CMD_IOT_NET_QUERY, CMD_IOT_TOPIC_QUERY, CMD_LOOP_PARAM_QUERY]: try: self._mqtt.send_command(sn, cmd) except Exception: pass def _query_ssc_net(self): self._send_cmd(CMD_SSC_NET_QUERY) def _set_ssc_net(self): self._send_cmd(CMD_SSC_NET_SET, data_ssc_net_set( dev_ip=self._var_ssc_ip.get(), subnet_mask=self._var_ssc_mask.get(), route_ip=self._var_ssc_gw.get(), lssc_ip=self._var_ssc_lssc.get(), dns=self._var_ssc_dns.get(), port=int(self._var_ssc_port.get() or "0"), )) def _query_iot_net(self): self._send_cmd(CMD_IOT_NET_QUERY) def _set_iot_net(self): self._send_cmd(CMD_IOT_NET_SET, data_iot_net_set( host=self._var_iot_host.get(), port=int(self._var_iot_port.get() or "1883"), client_id=self._var_iot_cid.get(), username=self._var_iot_user.get(), password=self._var_iot_pass.get(), )) def _query_iot_topic(self): self._send_cmd(CMD_IOT_TOPIC_QUERY) def _set_iot_topic(self): self._send_cmd(CMD_IOT_TOPIC_SET, data_iot_topic_set( client_id_enable=self._var_cid_enable.get(), topic_pub=self._var_topic_pub.get(), topic_sub=self._var_topic_sub.get(), )) def _do_pwd_verify(self): pwd = self._var_pwd.get() if not pwd: messagebox.showwarning("提示", "请输入密码") return self._send_cmd(CMD_PWD_VERIFY, data_pwd_verify(pwd)) def _do_pwd_set(self): old = self._var_pwd.get() new = self._var_new_pwd.get() if not old or not new: messagebox.showwarning("提示", "请输入旧密码和新密码") return self._send_cmd(CMD_PWD_SET, data_pwd_set(old, new)) def _do_factory_reset(self): if not messagebox.askyesno("确认", "确定要恢复出厂设置吗?此操作不可撤销!"): return self._send_cmd(CMD_FACTORY_RESET) def _do_device_reset(self): if not messagebox.askyesno("确认", "确定要复位设备吗?设备将重启。"): return self._send_cmd(CMD_DEVICE_RESET) # ================================================================ # UI 更新 # ================================================================ def _refresh_device_list(self): tree = self._dev_tree current = set(tree.get_children("")) devices = self._devmgr.devices for sn, dev in devices.items(): iid = sn status = "●" if dev.online else "○" display = f"{status} {sn}" model = dev.model or "?" if iid in current: tree.item(iid, text=display, values=(model,)) current.discard(iid) else: tree.insert("", tk.END, iid=iid, text=display, values=(model,)) for stale in current: tree.delete(stale) def _on_device_select(self, event): sn = self._selected_dev() if sn and sn in self._devmgr.devices: dev = self._devmgr.devices[sn] info = { "dev_serial": dev.dev_serial, "model": dev.model, "hard_ver": dev.hard_ver, "soft_ver": dev.soft_ver, "product_code": dev.product_code, "net_enabled": dev.net_enabled, "iot_enabled": dev.iot_enabled, "bus": dev.bus, "online": dev.online, "last_seen": dev.last_seen.strftime("%Y-%m-%d %H:%M:%S") if dev.last_seen else "—", } self._show_info(info) def _show_info(self, data: dict): self._info_text.configure(state=tk.NORMAL) self._info_text.delete("1.0", tk.END) self._info_text.insert(tk.END, json.dumps(data, indent=2, ensure_ascii=False)) self._info_text.configure(state=tk.DISABLED) def _append_data(self, widget: scrolledtext.ScrolledText, text: str): widget.configure(state=tk.NORMAL) ts = datetime.now().strftime("%H:%M:%S") widget.insert(tk.END, f"--- {ts} ---\n{text}\n\n") widget.see(tk.END) widget.configure(state=tk.DISABLED) def _log(self, msg: str): ts = datetime.now().strftime("%H:%M:%S") line = f"[{ts}] {msg}" self._log_lines.append(line) self._log_text.configure(state=tk.NORMAL) self._log_text.insert(tk.END, line + "\n") self._log_text.see(tk.END) self._log_text.configure(state=tk.DISABLED) def _clear_log(self): self._log_text.configure(state=tk.NORMAL) self._log_text.delete("1.0", tk.END) self._log_text.configure(state=tk.DISABLED) self._log_lines.clear() def main(): app = DBNMqttTool() app.mainloop() if __name__ == "__main__": main()