From a2cfe4602cd6c093499baab24fe00b4dd569e55e Mon Sep 17 00:00:00 2001 From: wangfq Date: Mon, 6 Jul 2026 18:14:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20DBNMQTTool=20=E2=80=94=20DLD960=20IoT?= =?UTF-8?q?=20MQTT=20=E8=AE=BE=E5=A4=87=E7=AE=A1=E7=90=86=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=20(Python/tkinter=20=E8=B7=A8=E5=B9=B3=E5=8F=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于《DLD960_IoT_MQTT协议.md》V1.00 实现 功能: - MQTT Broker 连接管理 - 设备自动发现(订阅 dld960/+/dev/# 通配符) - 设备信息查询(dev_info_query) - 网络配置(SSC/IoT TCP)+ Topic 配置 - 实时线圈数据监控(loop_data)+ 事件上报(event_report) - 心跳监控(heartbeat) - 控制命令:密码验证/设置、出厂初始化、设备复位 项目结构: - main.py 主窗口 (tkinter GUI) - dbn_mqtt_tool/ - protocol.py DLD960 IoT MQTT 协议定义 - mqtt_client.py MQTT 客户端封装 (paho-mqtt) - device_manager.py 设备发现与状态管理 --- DBNMQTTool/dbn_mqtt_tool/__init__.py | 0 DBNMQTTool/dbn_mqtt_tool/device_manager.py | 111 +++++ DBNMQTTool/dbn_mqtt_tool/mqtt_client.py | 141 ++++++ DBNMQTTool/dbn_mqtt_tool/protocol.py | 247 ++++++++++ DBNMQTTool/main.py | 498 +++++++++++++++++++++ 5 files changed, 997 insertions(+) create mode 100644 DBNMQTTool/dbn_mqtt_tool/__init__.py create mode 100644 DBNMQTTool/dbn_mqtt_tool/device_manager.py create mode 100644 DBNMQTTool/dbn_mqtt_tool/mqtt_client.py create mode 100644 DBNMQTTool/dbn_mqtt_tool/protocol.py create mode 100644 DBNMQTTool/main.py diff --git a/DBNMQTTool/dbn_mqtt_tool/__init__.py b/DBNMQTTool/dbn_mqtt_tool/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/DBNMQTTool/dbn_mqtt_tool/device_manager.py b/DBNMQTTool/dbn_mqtt_tool/device_manager.py new file mode 100644 index 0000000..0ae1398 --- /dev/null +++ b/DBNMQTTool/dbn_mqtt_tool/device_manager.py @@ -0,0 +1,111 @@ +""" +设备管理器 — 管理已发现的设备及其状态 +""" + +import threading +from typing import Callable, Optional +from dataclasses import dataclass, field +from datetime import datetime + + +@dataclass +class DeviceInfo: + dev_serial: str + model: str = "" + hard_ver: str = "" + soft_ver: str = "" + product_code: str = "" + net_enabled: bool = False + iot_enabled: bool = False + bus: dict = field(default_factory=dict) + last_seen: Optional[datetime] = None + online: bool = False + loop_data: Optional[dict] = None + events: list = field(default_factory=list) + + +class DeviceManager: + """管理设备列表和状态""" + + def __init__(self): + self._devices: dict[str, DeviceInfo] = {} + self._lock = threading.Lock() + self._callbacks: list[Callable] = [] + + def on_change(self, callback: Callable): + self._callbacks.append(callback) + + def _notify(self): + for cb in self._callbacks: + try: + cb() + except Exception: + pass + + def get_or_create(self, dev_serial: str) -> DeviceInfo: + with self._lock: + if dev_serial not in self._devices: + self._devices[dev_serial] = DeviceInfo(dev_serial=dev_serial) + return self._devices[dev_serial] + + def update_from_dev_info(self, dev_serial: str, data: dict): + with self._lock: + dev = self.get_or_create(dev_serial) + dev.dev_serial = data.get("dev_serial", dev_serial) + dev.model = data.get("model", dev.model) + dev.hard_ver = data.get("hard_ver", dev.hard_ver) + dev.soft_ver = data.get("soft_ver", dev.soft_ver) + dev.product_code = data.get("product_code", dev.product_code) + sc = data.get("sub_code", {}) + dev.net_enabled = sc.get("net", dev.net_enabled) + dev.iot_enabled = sc.get("iot", dev.iot_enabled) + dev.bus = data.get("bus", dev.bus) + dev.last_seen = datetime.now() + dev.online = True + self._notify() + + def update_loop_data(self, dev_serial: str, data: dict): + with self._lock: + dev = self.get_or_create(dev_serial) + dev.loop_data = data + dev.last_seen = datetime.now() + dev.online = True + self._notify() + + def update_event(self, dev_serial: str, data: dict): + with self._lock: + dev = self.get_or_create(dev_serial) + events = data.get("events", []) + for evt in events: + evt["_ts"] = datetime.now() + dev.events.insert(0, evt) + dev.events = dev.events[:100] # 保留最近 100 条 + dev.last_seen = datetime.now() + dev.online = True + self._notify() + + def update_heartbeat(self, dev_serial: str, data: dict): + with self._lock: + dev = self.get_or_create(dev_serial) + dev.last_seen = datetime.now() + dev.online = True + self._notify() + + def set_offline(self): + """标记所有设备离线(断开连接时调用)""" + with self._lock: + now = datetime.now() + for dev in self._devices.values(): + if dev.last_seen and (now - dev.last_seen).seconds > 120: + dev.online = False + self._notify() + + @property + def devices(self) -> dict[str, DeviceInfo]: + with self._lock: + return dict(self._devices) + + @property + def online_devices(self) -> list[DeviceInfo]: + with self._lock: + return [d for d in self._devices.values() if d.online] diff --git a/DBNMQTTool/dbn_mqtt_tool/mqtt_client.py b/DBNMQTTool/dbn_mqtt_tool/mqtt_client.py new file mode 100644 index 0000000..af9a844 --- /dev/null +++ b/DBNMQTTool/dbn_mqtt_tool/mqtt_client.py @@ -0,0 +1,141 @@ +""" +MQTT 客户端封装 +""" + +import json +import time +import threading +from typing import Callable, Optional +from dataclasses import dataclass + +import paho.mqtt.client as mqtt + +from .protocol import parse_topic_dev_serial + + +@dataclass +class BrokerConfig: + host: str = "121.37.20.199" + port: int = 1883 + username: str = "" + password: str = "" + client_id: str = "" + keepalive: int = 60 + + +class MqttClient: + """DLD960 IoT MQTT 客户端""" + + def __init__(self): + self._client: Optional[mqtt.Client] = None + self._config = BrokerConfig() + self._connected = False + self._lock = threading.Lock() + self._message_callbacks: list[Callable[[str, str, dict], None]] = [] + self._status_callbacks: list[Callable[[bool, str], None]] = [] + + @property + def connected(self) -> bool: + return self._connected + + def on_status_change(self, callback: Callable[[bool, str], None]): + self._status_callbacks.append(callback) + + def on_message(self, callback: Callable[[str, str, dict], None]): + """回调参数: (topic, dev_serial, payload_dict)""" + self._message_callbacks.append(callback) + + def _notify_status(self, connected: bool, msg: str): + for cb in self._status_callbacks: + try: + cb(connected, msg) + except Exception: + pass + + def _notify_message(self, topic: str, payload: dict): + dev_serial = parse_topic_dev_serial(topic) or "" + for cb in self._message_callbacks: + try: + cb(topic, dev_serial, payload) + except Exception: + pass + + # ---- MQTT callbacks ---- + + def _on_connect(self, client, userdata, flags, reason_code, properties=None): + rc = reason_code if isinstance(reason_code, int) else reason_code.value + if rc == 0: + self._connected = True + self._notify_status(True, "已连接到 Broker") + + # 订阅所有设备的上报 topic + from .protocol import ( + TOPIC_ALL_CONFIG_RESP, TOPIC_ALL_DATA_LOOP, + TOPIC_ALL_DATA_EVENT, TOPIC_ALL_STATUS + ) + client.subscribe(TOPIC_ALL_CONFIG_RESP, qos=1) + client.subscribe(TOPIC_ALL_DATA_LOOP, qos=1) + client.subscribe(TOPIC_ALL_DATA_EVENT, qos=1) + client.subscribe(TOPIC_ALL_STATUS, qos=0) + else: + self._connected = False + self._notify_status(False, f"连接失败 (rc={rc})") + + def _on_disconnect(self, client, userdata, flags, reason_code, properties=None): + self._connected = False + self._notify_status(False, "已断开连接") + + def _on_message(self, client, userdata, msg: mqtt.MQTTMessage): + try: + payload = json.loads(msg.payload.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + payload = {"_raw": msg.payload.hex()} + self._notify_message(msg.topic, payload) + + # ---- Public API ---- + + def connect(self, config: Optional[BrokerConfig] = None): + if config: + self._config = config + + if self._client: + self.disconnect() + + client_id = self._config.client_id or f"dbn_mqtt_tool_{int(time.time())}" + + # MQTT 5.0 用 CallbackAPIVersion.VERSION2 + self._client = mqtt.Client( + client_id=client_id, + protocol=mqtt.MQTTv311, + ) + if self._config.username: + self._client.username_pw_set(self._config.username, self._config.password) + + self._client.on_connect = self._on_connect + self._client.on_disconnect = self._on_disconnect + self._client.on_message = self._on_message + + self._client.connect_async(self._config.host, self._config.port, self._config.keepalive) + self._client.loop_start() + + def disconnect(self): + if self._client: + self._client.loop_stop() + self._client.disconnect() + self._client = None + self._connected = False + + def publish(self, topic: str, payload: dict, qos: int = 1): + """发送 JSON 消息到指定 topic""" + if not self._client or not self._connected: + raise ConnectionError("MQTT 未连接") + data = json.dumps(payload, ensure_ascii=False) + self._client.publish(topic, data, qos=qos) + + def send_command(self, dev_serial: str, cmd: str, data: Optional[dict] = None, qos: int = 1) -> int: + """向设备发送命令,返回 msg_id""" + from .protocol import build_request, get_topic_for_cmd + msg = build_request(cmd, data) + topic = get_topic_for_cmd(cmd, dev_serial) + self.publish(topic, msg, qos=qos) + return msg["msg_id"] diff --git a/DBNMQTTool/dbn_mqtt_tool/protocol.py b/DBNMQTTool/dbn_mqtt_tool/protocol.py new file mode 100644 index 0000000..8e2de64 --- /dev/null +++ b/DBNMQTTool/dbn_mqtt_tool/protocol.py @@ -0,0 +1,247 @@ +""" +DLD960 IoT MQTT 协议定义 + +基于《DLD960_IoT_MQTT协议.md》V1.00 +""" + +import json +import time +from typing import Optional, Any +from dataclasses import dataclass, field, asdict + + +# ============================================================ +# Topic 结构 +# ============================================================ + +def topic_config_set(dev_serial: str) -> str: + """服务器下发 — 设置配置""" + return f"dld960/{dev_serial}/srv/config/set" + + +def topic_config_query(dev_serial: str) -> str: + """服务器下发 — 查询配置""" + return f"dld960/{dev_serial}/srv/config/query" + + +def topic_ctrl(dev_serial: str) -> str: + """服务器下发 — 控制命令""" + return f"dld960/{dev_serial}/srv/ctrl" + + +def topic_config_resp(dev_serial: str) -> str: + """设备上报 — 配置响应""" + return f"dld960/{dev_serial}/dev/config/resp" + + +def topic_data_loop(dev_serial: str) -> str: + """设备上报 — 线圈数据""" + return f"dld960/{dev_serial}/dev/data/loop" + + +def topic_data_event(dev_serial: str) -> str: + """设备上报 — 事件""" + return f"dld960/{dev_serial}/dev/data/event" + + +def topic_status(dev_serial: str) -> str: + """设备上报 — 状态/心跳""" + return f"dld960/{dev_serial}/dev/status" + + +# 订阅通配符(监听所有设备) +TOPIC_ALL_CONFIG_RESP = "dld960/+/dev/config/resp" +TOPIC_ALL_DATA_LOOP = "dld960/+/dev/data/loop" +TOPIC_ALL_DATA_EVENT = "dld960/+/dev/data/event" +TOPIC_ALL_STATUS = "dld960/+/dev/status" + + +# ============================================================ +# 命令枚举 +# ============================================================ + +CMD_DEV_SERIAL_SET = "dev_serial_set" +CMD_DEV_INFO_QUERY = "dev_info_query" +CMD_SSC_NET_SET = "ssc_net_set" +CMD_SSC_NET_QUERY = "ssc_net_query" +CMD_IOT_NET_SET = "iot_net_set" +CMD_IOT_NET_QUERY = "iot_net_query" +CMD_IOT_TOPIC_SET = "iot_topic_set" +CMD_IOT_TOPIC_QUERY = "iot_topic_query" +CMD_PWD_VERIFY = "pwd_verify" +CMD_PWD_SET = "pwd_set" +CMD_FACTORY_RESET = "factory_reset" +CMD_DEVICE_RESET = "device_reset" +CMD_LOOP_PARAM_SET = "loop_param_set" +CMD_LOOP_PARAM_QUERY = "loop_param_query" +CMD_REPORT_CONFIG = "report_config" + +# 设备上报 +CMD_LOOP_DATA = "loop_data" +CMD_EVENT_REPORT = "event_report" +CMD_HEARTBEAT = "heartbeat" + +# 配置类命令 → topic +CONFIG_SET_COMMANDS = {CMD_DEV_SERIAL_SET, CMD_SSC_NET_SET, CMD_IOT_NET_SET, CMD_IOT_TOPIC_SET, CMD_LOOP_PARAM_SET} +CONFIG_QUERY_COMMANDS = {CMD_DEV_INFO_QUERY, CMD_SSC_NET_QUERY, CMD_IOT_NET_QUERY, CMD_IOT_TOPIC_QUERY, CMD_LOOP_PARAM_QUERY} +CTRL_COMMANDS = {CMD_PWD_VERIFY, CMD_PWD_SET, CMD_FACTORY_RESET, CMD_DEVICE_RESET, CMD_REPORT_CONFIG} + + +# ============================================================ +# 错误码 +# ============================================================ + +ERR_SUCCESS = 0 +ERR_PARAM_ERROR = 1 +ERR_PWD_FAILED = 2 +ERR_DEVICE_BUSY = 3 +ERR_UNSUPPORTED = 4 +ERR_INTERNAL = 5 +ERR_DATA_TOO_LONG = 6 + +ERROR_MSGS = { + 0: "成功", + 1: "参数错误", + 2: "密码验证失败", + 3: "设备忙", + 4: "不支持的命令", + 5: "内部错误", + 6: "数据超长", +} + + +# ============================================================ +# 频率档位 / 输出模式 / 事件类型 +# ============================================================ + +FREQ_LEVELS = ["high", "mid_high", "mid_low", "low"] +OUTPUT_MODES = ["exist", "enter_pulse", "leave_pulse", "direction"] +EVENT_TYPES = ["car_enter", "car_leave", "loop_cut", "loop_restore"] + + +# ============================================================ +# 消息构建 +# ============================================================ + +_msg_id_counter = 0 + +def next_msg_id() -> int: + global _msg_id_counter + _msg_id_counter += 1 + return _msg_id_counter + + +def build_request(cmd: str, data: Optional[dict] = None) -> dict: + """构建通用请求消息""" + msg: dict[str, Any] = { + "msg_id": next_msg_id(), + "cmd": cmd, + "ts": int(time.time()), + } + if data is not None: + msg["data"] = data + return msg + + +def build_response(cmd: str, msg_id: int, code: int = 0, msg_text: str = "success", data: Optional[dict] = None) -> dict: + """构建响应消息""" + resp: dict[str, Any] = { + "msg_id": msg_id, + "cmd": cmd, + "ts": int(time.time()), + "code": code, + "msg": msg_text, + } + if data is not None: + resp["data"] = data + return resp + + +def get_topic_for_cmd(cmd: str, dev_serial: str) -> str: + """根据命令获取对应的下发 topic""" + if cmd in CONFIG_SET_COMMANDS: + return topic_config_set(dev_serial) + elif cmd in CONFIG_QUERY_COMMANDS: + return topic_config_query(dev_serial) + elif cmd in CTRL_COMMANDS: + return topic_ctrl(dev_serial) + else: + return topic_config_set(dev_serial) # 默认 + + +# ============================================================ +# 特定命令的 data 构建器 +# ============================================================ + +def data_dev_serial_set(dev_serial: str) -> dict: + return {"dev_serial": dev_serial} + + +def data_ssc_net_set(dev_ip: str, subnet_mask: str, route_ip: str, + lssc_ip: str, dns: str, port: int) -> dict: + return { + "dev_ip": dev_ip, + "subnet_mask": subnet_mask, + "route_ip": route_ip, + "lssc_ip": lssc_ip, + "dns": dns, + "port": port, + } + + +def data_iot_net_set(host: str, port: int = 1883, + client_id: str = "", username: str = "", password: str = "") -> dict: + return { + "host": host, + "port": port, + "client_id": client_id, + "username": username, + "password": password, + } + + +def data_iot_topic_set(client_id_enable: bool, topic_pub: str, topic_sub: str) -> dict: + return { + "client_id_enable": client_id_enable, + "topic_pub": topic_pub, + "topic_sub": topic_sub, + } + + +def data_pwd_verify(password: str) -> dict: + return {"password": password} + + +def data_pwd_set(old_password: str, new_password: str) -> dict: + return {"old_password": old_password, "new_password": new_password} + + +def data_loop_param_set(channels: list[dict], auto_mode: bool = False) -> dict: + return {"auto_mode": auto_mode, "channels": channels} + + +def data_report_config(sensor_type: int = 12, enable: bool = True, + once: bool = False, env_eval: bool = False, + interval: int = 5, ack_required: bool = False, + timeout: int = 0) -> dict: + return { + "sensor_type": sensor_type, + "enable": enable, + "once": once, + "env_eval": env_eval, + "interval": interval, + "ack_required": ack_required, + "timeout": timeout, + } + + +# ============================================================ +# 解析设备上报 +# ============================================================ + +def parse_topic_dev_serial(topic: str) -> Optional[str]: + """从 topic 中提取设备序列码""" + parts = topic.split("/") + if len(parts) >= 2 and parts[0] == "dld960": + return parts[1] + return None diff --git a/DBNMQTTool/main.py b/DBNMQTTool/main.py new file mode 100644 index 0000000..3b77525 --- /dev/null +++ b/DBNMQTTool/main.py @@ -0,0 +1,498 @@ +#!/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()