feat: DBNMQTTool — DLD960 IoT MQTT 设备管理工具 (Python/tkinter 跨平台)

基于《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 设备发现与状态管理
This commit is contained in:
wangfq
2026-07-06 18:14:38 +08:00
parent 220f778117
commit a2cfe4602c
5 changed files with 997 additions and 0 deletions
+111
View File
@@ -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]
+141
View File
@@ -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"]
+247
View File
@@ -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
+498
View File
@@ -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("<<TreeviewSelect>>", 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()