Files
vd_960/DBNMQTTool/main.py
T
wangfq 11d1a96d24 同步协议 V1.03:双主题发布对齐 + initialize 数据格式修正
vd960DBN:
- heartbeat/sensor/initialize 发布统一走 dld960/{sn}/dev
- dev_initialize_pub 补 model/hard_ver/soft_ver,删 extra_info.version

DBNMQTTool:
- 协议版本号更新至 V1.03
- 协议 Topic 树和模拟页增加 initialize 支持
2026-07-10 08:49:28 +08:00

1164 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
DBN MQTT Tool — DLD960 IoT MQTT 设备管理工具
跨平台桌面软件 (PySide6)
"""
import sys
import os
import json
from datetime import datetime
from typing import Optional
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QLineEdit, QPushButton, QTreeWidget, QTreeWidgetItem,
QTabWidget, QTextEdit, QGroupBox, QGridLayout, QCheckBox,
QSplitter, QMessageBox, QHeaderView, QFrame,
QComboBox, QSpinBox, QPlainTextEdit,
)
from PySide6.QtCore import Qt, QTimer, Signal, Slot
from PySide6.QtGui import QFont, QColor, QTextCursor
import paho.mqtt.client as mqtt
from dbn_mqtt_tool.mqtt_client import MqttClient, BrokerConfig
from dbn_mqtt_tool.device_manager import DeviceManager
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_REPORT_CONFIG,
CMD_SSC_NET_SET, CMD_IOT_NET_SET, CMD_IOT_TOPIC_SET,
CMD_PWD_VERIFY, CMD_PWD_SET, CMD_FACTORY_RESET, CMD_DEVICE_RESET,
CMD_LOOP_DATA, CMD_EVENT_REPORT, CMD_HEARTBEAT, CMD_INITIALIZE,
ERROR_MSGS, FREQ_LEVELS, OUTPUT_MODES, EVENT_TYPES,
data_ssc_net_set, data_iot_net_set, data_iot_topic_set,
data_pwd_verify, data_pwd_set, data_report_config,
build_request, next_msg_id,
topic_down, topic_up,
)
class MainWindow(QMainWindow):
_mqtt_status = Signal(bool, str)
_mqtt_msg = Signal(str, str, object)
_devices_changed = Signal()
def __init__(self):
super().__init__()
self.setWindowTitle("DBN MQTT Tool — DLD960 IoT 设备管理")
self.resize(1280, 800)
self.setMinimumSize(960, 640)
self._mqtt = MqttClient()
self._devmgr = DeviceManager()
self._mqtt.on_status_change(lambda c, m: self._mqtt_status.emit(c, m))
self._mqtt.on_message(lambda t, s, p: self._mqtt_msg.emit(t, s, p))
self._devmgr.on_change(lambda: self._devices_changed.emit())
self._mqtt_status.connect(self._on_status)
self._mqtt_msg.connect(self._on_message)
self._devices_changed.connect(self._refresh_devices)
self._build_ui()
self._log("DBN MQTT Tool 已启动")
# ================================================================
# UI 构建
# ================================================================
def _build_ui(self):
central = QWidget()
self.setCentralWidget(central)
root = QVBoxLayout(central)
root.setContentsMargins(8, 8, 8, 8)
root.setSpacing(6)
# -- 顶栏 --
root.addWidget(self._build_top_bar())
# -- 主体 --
splitter = QSplitter(Qt.Horizontal)
left = self._build_device_panel()
splitter.addWidget(left)
self._notebook = QTabWidget()
self._notebook.addTab(self._build_info_tab(), "设备信息")
self._notebook.addTab(self._build_config_tab(), "参数配置")
self._notebook.addTab(self._build_data_tab(), "实时数据")
self._notebook.addTab(self._build_log_tab(), "日志")
self._notebook.addTab(self._build_simulate_tab(), "模拟上报")
self._notebook.addTab(self._build_proto_topic_tab(), "协议Topic")
self._notebook.addTab(self._build_custom_topic_tab(), "自定义Topic")
splitter.addWidget(self._notebook)
splitter.setSizes([280, 960])
root.addWidget(splitter, 1)
def _build_top_bar(self) -> QWidget:
bar = QWidget()
layout = QHBoxLayout(bar)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(6)
layout.addWidget(QLabel("Broker:"))
self._host_edit = QLineEdit("121.37.20.199")
self._host_edit.setMaximumWidth(140)
layout.addWidget(self._host_edit)
layout.addWidget(QLabel("Port:"))
self._port_edit = QLineEdit("1883")
self._port_edit.setMaximumWidth(60)
layout.addWidget(self._port_edit)
layout.addWidget(QLabel("用户:"))
self._user_edit = QLineEdit()
self._user_edit.setMaximumWidth(90)
layout.addWidget(self._user_edit)
layout.addWidget(QLabel("密码:"))
self._pass_edit = QLineEdit()
self._pass_edit.setMaximumWidth(90)
self._pass_edit.setEchoMode(QLineEdit.Password)
layout.addWidget(self._pass_edit)
self._btn_conn = QPushButton("连接")
self._btn_conn.setFixedWidth(70)
self._btn_conn.clicked.connect(self._toggle_connect)
layout.addWidget(self._btn_conn)
self._status_label = QLabel("● 未连接")
self._status_label.setStyleSheet("color: red; font-weight: bold;")
layout.addWidget(self._status_label)
layout.addStretch()
return bar
def _build_device_panel(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(QLabel("<b>设备列表</b>"))
self._dev_tree = QTreeWidget()
self._dev_tree.setHeaderLabels(["设备序列码", "型号"])
self._dev_tree.setRootIsDecorated(False)
self._dev_tree.header().setStretchLastSection(True)
self._dev_tree.itemSelectionChanged.connect(self._on_dev_select)
layout.addWidget(self._dev_tree, 1)
btn_row = QHBoxLayout()
btn_query = QPushButton("查询设备信息")
btn_query.clicked.connect(self._query_dev_info)
btn_row.addWidget(btn_query)
btn_all = QPushButton("查询全部")
btn_all.clicked.connect(self._query_all)
btn_row.addWidget(btn_all)
layout.addLayout(btn_row)
return w
def _build_info_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
self._info_text = QTextEdit()
self._info_text.setReadOnly(True)
self._info_text.setFont(QFont("Consolas", 10))
layout.addWidget(self._info_text)
return w
def _build_config_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
# -- SSC 网络 --
g1 = QGroupBox("SSC 网络配置")
g1_layout = QGridLayout(g1)
fields_ssc = [
("设备 IP", "ssc_ip"), ("子网掩码", "ssc_mask"),
("网关", "ssc_gw"), ("LSSC IP", "ssc_lssc"),
("DNS", "ssc_dns"), ("端口", "ssc_port"),
]
for i, (label, name) in enumerate(fields_ssc):
g1_layout.addWidget(QLabel(label + ":"), i // 2, (i % 2) * 2)
edit = QLineEdit()
edit.setMaximumWidth(140)
g1_layout.addWidget(edit, i // 2, (i % 2) * 2 + 1)
setattr(self, f"_edit_{name}", edit)
btn_row = QHBoxLayout()
b1 = QPushButton("查询")
b1.clicked.connect(self._query_ssc_net)
b2 = QPushButton("设置")
b2.clicked.connect(self._set_ssc_net)
btn_row.addWidget(b1)
btn_row.addWidget(b2)
btn_row.addStretch()
g1_layout.addLayout(btn_row, 3, 0, 1, 4)
layout.addWidget(g1)
# -- IoT 网络 --
g2 = QGroupBox("IoT 网络配置")
g2_layout = QGridLayout(g2)
fields_iot = [
("MQTT Host", "iot_host"), ("MQTT Port", "iot_port"),
("Client ID", "iot_cid"), ("用户名", "iot_user"),
("密码", "iot_pass"),
]
for i, (label, name) in enumerate(fields_iot):
g2_layout.addWidget(QLabel(label + ":"), i, 0)
edit = QLineEdit()
edit.setMinimumWidth(200)
if "pass" in name:
edit.setEchoMode(QLineEdit.Password)
g2_layout.addWidget(edit, i, 1)
setattr(self, f"_edit_{name}", edit)
btn_row2 = QHBoxLayout()
b3 = QPushButton("查询")
b3.clicked.connect(self._query_iot_net)
b4 = QPushButton("设置")
b4.clicked.connect(self._set_iot_net)
btn_row2.addWidget(b3)
btn_row2.addWidget(b4)
btn_row2.addStretch()
g2_layout.addLayout(btn_row2, len(fields_iot), 0, 1, 2)
layout.addWidget(g2)
# -- Topic --
g3 = QGroupBox("IoT Topic 配置")
g3_layout = QGridLayout(g3)
self._chk_cid = QCheckBox("Client ID 启用")
self._chk_cid.setChecked(True)
g3_layout.addWidget(self._chk_cid, 0, 0, 1, 2)
g3_layout.addWidget(QLabel("Topic 发布:"), 1, 0)
self._edit_topic_pub = QLineEdit()
self._edit_topic_pub.setMinimumWidth(250)
g3_layout.addWidget(self._edit_topic_pub, 1, 1)
g3_layout.addWidget(QLabel("Topic 订阅:"), 2, 0)
self._edit_topic_sub = QLineEdit()
g3_layout.addWidget(self._edit_topic_sub, 2, 1)
btn_row3 = QHBoxLayout()
b5 = QPushButton("查询")
b5.clicked.connect(self._query_iot_topic)
b6 = QPushButton("设置")
b6.clicked.connect(self._set_iot_topic)
btn_row3.addWidget(b5)
btn_row3.addWidget(b6)
btn_row3.addStretch()
g3_layout.addLayout(btn_row3, 3, 0, 1, 2)
layout.addWidget(g3)
# -- 控制 --
g4 = QGroupBox("控制命令")
g4_layout = QGridLayout(g4)
g4_layout.addWidget(QLabel("密码:"), 0, 0)
self._edit_pwd = QLineEdit()
self._edit_pwd.setMaximumWidth(100)
self._edit_pwd.setEchoMode(QLineEdit.Password)
g4_layout.addWidget(self._edit_pwd, 0, 1)
g4_layout.addWidget(QLabel("新密码:"), 0, 2)
self._edit_new_pwd = QLineEdit()
self._edit_new_pwd.setMaximumWidth(100)
self._edit_new_pwd.setEchoMode(QLineEdit.Password)
g4_layout.addWidget(self._edit_new_pwd, 0, 3)
btn_row4 = QHBoxLayout()
for label, slot in [
("验证密码", self._do_pwd_verify), ("设置密码", self._do_pwd_set),
("出厂初始化", self._do_factory_reset), ("设备复位", self._do_device_reset),
]:
btn = QPushButton(label)
btn.clicked.connect(slot)
btn_row4.addWidget(btn)
btn_row4.addStretch()
g4_layout.addLayout(btn_row4, 1, 0, 1, 4)
layout.addWidget(g4)
# -- 主动上报 --
g5 = QGroupBox("主动上报配置 (report_config)")
g5_layout = QGridLayout(g5)
g5_layout.addWidget(QLabel("传感器类型:"), 0, 0)
self._edit_report_sensor_type = QSpinBox()
self._edit_report_sensor_type.setRange(0, 255)
self._edit_report_sensor_type.setValue(12)
g5_layout.addWidget(self._edit_report_sensor_type, 0, 1)
g5_layout.addWidget(QLabel("上报间隔(s):"), 0, 2)
self._edit_report_interval = QSpinBox()
self._edit_report_interval.setRange(0, 86400)
self._edit_report_interval.setValue(5)
g5_layout.addWidget(self._edit_report_interval, 0, 3)
g5_layout.addWidget(QLabel("超时(ms):"), 0, 4)
self._edit_report_timeout = QSpinBox()
self._edit_report_timeout.setRange(0, 60000)
g5_layout.addWidget(self._edit_report_timeout, 0, 5)
self._chk_report_enable = QCheckBox("启用上报")
self._chk_report_enable.setChecked(True)
g5_layout.addWidget(self._chk_report_enable, 1, 0)
self._chk_report_once = QCheckBox("单次上报")
g5_layout.addWidget(self._chk_report_once, 1, 1)
self._chk_report_env = QCheckBox("环境评估")
g5_layout.addWidget(self._chk_report_env, 1, 2)
self._chk_report_ack = QCheckBox("需应答")
g5_layout.addWidget(self._chk_report_ack, 1, 3)
btn_row5 = QHBoxLayout()
b7 = QPushButton("查询")
b7.clicked.connect(self._query_report_config)
b8 = QPushButton("设置")
b8.clicked.connect(self._set_report_config)
btn_row5.addWidget(b7)
btn_row5.addWidget(b8)
btn_row5.addStretch()
g5_layout.addLayout(btn_row5, 2, 0, 1, 6)
layout.addWidget(g5)
layout.addStretch()
return w
def _build_data_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
layout.addWidget(QLabel("<b>线圈传感数据 (loop_data)</b>"))
self._loop_text = QTextEdit()
self._loop_text.setReadOnly(True)
self._loop_text.setFont(QFont("Consolas", 10))
layout.addWidget(self._loop_text, 2)
layout.addWidget(QLabel("<b>事件上报 (event_report)</b>"))
self._event_text = QTextEdit()
self._event_text.setReadOnly(True)
self._event_text.setFont(QFont("Consolas", 10))
layout.addWidget(self._event_text, 1)
return w
def _build_log_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
self._log_text = QTextEdit()
self._log_text.setReadOnly(True)
self._log_text.setFont(QFont("Consolas", 9))
layout.addWidget(self._log_text, 1)
btn_clear = QPushButton("清空")
btn_clear.clicked.connect(self._log_text.clear)
layout2 = QHBoxLayout()
layout2.addStretch()
layout2.addWidget(btn_clear)
layout.addLayout(layout2)
return w
# ================================================================
# 模拟设备上报
# ================================================================
def _build_simulate_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
# -- 设备序列码 --
row1 = QHBoxLayout()
row1.addWidget(QLabel("模拟设备 SN:"))
self._sim_sn = QLineEdit("DC045A49718F")
self._sim_sn.setMaximumWidth(160)
row1.addWidget(self._sim_sn)
row1.addStretch()
layout.addLayout(row1)
# -- loop_data --
g1 = QGroupBox("线圈数据上报 (loop_data)")
g1l = QVBoxLayout(g1)
self._sim_loop = QPlainTextEdit()
self._sim_loop.setFont(QFont("Consolas", 10))
self._sim_loop.setMaximumBlockCount(2000)
self._sim_loop.setPlainText(self._sample_loop_data())
g1l.addWidget(self._sim_loop)
btn_row1 = QHBoxLayout()
btn_row1.addWidget(QLabel("上报间隔(s):"))
self._sim_loop_interval = QSpinBox()
self._sim_loop_interval.setRange(1, 3600)
self._sim_loop_interval.setValue(5)
btn_row1.addWidget(self._sim_loop_interval)
b1 = QPushButton("发送一次")
b1.clicked.connect(self._sim_send_loop)
btn_row1.addWidget(b1)
self._sim_loop_btn = QPushButton("开始周期上报")
self._sim_loop_btn.setCheckable(True)
self._sim_loop_btn.toggled.connect(self._sim_toggle_loop)
btn_row1.addWidget(self._sim_loop_btn)
btn_row1.addStretch()
g1l.addLayout(btn_row1)
layout.addWidget(g1)
# -- event_report --
g2 = QGroupBox("事件上报 (event_report)")
g2l = QVBoxLayout(g2)
self._sim_event = QPlainTextEdit()
self._sim_event.setFont(QFont("Consolas", 10))
self._sim_event.setPlainText(self._sample_event_data())
g2l.addWidget(self._sim_event)
g2l2 = QHBoxLayout()
b2 = QPushButton("发送")
b2.clicked.connect(self._sim_send_event)
g2l2.addWidget(b2)
g2l2.addStretch()
g2l.addLayout(g2l2)
layout.addWidget(g2)
# -- initialize --
g_init = QGroupBox("上电初始化 (initialize) — V1.03")
g_init_l = QVBoxLayout(g_init)
self._sim_init = QPlainTextEdit()
self._sim_init.setFont(QFont("Consolas", 10))
self._sim_init.setPlainText(self._sample_initialize_data())
g_init_l.addWidget(self._sim_init)
g_init_l2 = QHBoxLayout()
b_init = QPushButton("发送")
b_init.clicked.connect(self._sim_send_initialize)
g_init_l2.addWidget(b_init)
g_init_l2.addStretch()
g_init_l.addLayout(g_init_l2)
layout.addWidget(g_init)
# -- heartbeat --
g3 = QGroupBox("心跳上报 (heartbeat)")
g3l = QVBoxLayout(g3)
self._sim_hb = QPlainTextEdit()
self._sim_hb.setFont(QFont("Consolas", 10))
self._sim_hb.setPlainText(self._sample_heartbeat_data())
g3l.addWidget(self._sim_hb)
g3l2 = QHBoxLayout()
g3l2.addWidget(QLabel("间隔(s):"))
self._sim_hb_interval = QSpinBox()
self._sim_hb_interval.setRange(5, 3600)
self._sim_hb_interval.setValue(60)
g3l2.addWidget(self._sim_hb_interval)
b3 = QPushButton("发送一次")
b3.clicked.connect(self._sim_send_hb)
g3l2.addWidget(b3)
self._sim_hb_btn = QPushButton("开始周期上报")
self._sim_hb_btn.setCheckable(True)
self._sim_hb_btn.toggled.connect(self._sim_toggle_hb)
g3l2.addWidget(self._sim_hb_btn)
g3l2.addStretch()
g3l.addLayout(g3l2)
layout.addWidget(g3)
# timers
self._sim_loop_timer = QTimer()
self._sim_loop_timer.timeout.connect(self._sim_send_loop)
self._sim_hb_timer = QTimer()
self._sim_hb_timer.timeout.connect(self._sim_send_hb)
return w
def _sample_loop_data(self) -> str:
return json.dumps({
"msg_id": 100,
"cmd": "loop_data",
"ts": 1719000100,
"data": {
"channels": [
{"ch": 1, "level": "high", "iscar": False, "loop_ok": True,
"freq": 105280, "diff": 20, "sens": 7, "cndtn": 0,
"misc": {"type": "time", "value": 0}},
{"ch": 2, "level": "mid_high", "iscar": True, "loop_ok": True,
"freq": 98700, "diff": 1500, "sens": 7, "cndtn": 2,
"misc": {"type": "time", "value": 350}},
]
}
}, indent=2)
def _sample_event_data(self) -> str:
return json.dumps({
"msg_id": 101,
"cmd": "event_report",
"ts": 1719000200,
"data": {"events": [
{"type": "car_enter", "ch": 2, "value": 0},
{"type": "car_leave", "ch": 2, "value": 350},
]}
}, indent=2)
def _sample_initialize_data(self) -> str:
return json.dumps({
"msg_id": 1,
"cmd": "initialize",
"ts": 1719000000,
"data": {
"dev_serial": "DC045A49718F",
"model": "DLD960",
"hard_ver": "1.0",
"soft_ver": "1.0",
"extra_info": {
"code": "869756049404948",
"csq": "21",
"location": "113.9237976,022.6400375"
}
}
}, indent=2)
def _sample_heartbeat_data(self) -> str:
return json.dumps({
"msg_id": 200,
"cmd": "heartbeat",
"ts": 1719000060,
"data": {"uptime": 3600, "loop_status": [True, True, False, True],
"net_status": True, "iot_status": True}
}, indent=2)
def _sim_publish(self, topic: str, text: str):
if not self._mqtt.connected:
QMessageBox.warning(self, "提示", "请先连接 MQTT Broker")
return
try:
payload = json.loads(text)
self._mqtt.publish(topic, payload, qos=1)
self._log_send("[模拟]", topic, payload)
except json.JSONDecodeError as e:
QMessageBox.warning(self, "JSON 错误", str(e))
except Exception as e:
QMessageBox.critical(self, "发布失败", f"{type(e).__name__}: {e}")
def _sim_send_loop(self):
sn = self._sim_sn.text()
self._sim_publish(topic_up(sn), self._sim_loop.toPlainText())
def _sim_send_event(self):
sn = self._sim_sn.text()
self._sim_publish(topic_up(sn), self._sim_event.toPlainText())
def _sim_send_initialize(self):
sn = self._sim_sn.text()
self._sim_publish(topic_up(sn), self._sim_init.toPlainText())
def _sim_send_hb(self):
sn = self._sim_sn.text()
self._sim_publish(topic_up(sn), self._sim_hb.toPlainText())
def _sim_toggle_loop(self, checked: bool):
if checked:
self._sim_loop_timer.start(self._sim_loop_interval.value() * 1000)
self._sim_loop_btn.setText("停止")
else:
self._sim_loop_timer.stop()
self._sim_loop_btn.setText("开始周期上报")
def _sim_toggle_hb(self, checked: bool):
if checked:
self._sim_hb_timer.start(self._sim_hb_interval.value() * 1000)
self._sim_hb_btn.setText("停止")
else:
self._sim_hb_timer.stop()
self._sim_hb_btn.setText("开始周期上报")
# ================================================================
# 协议 Topic 订阅/发布
# ================================================================
def _build_proto_topic_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
row1 = QHBoxLayout()
row1.addWidget(QLabel("设备 SN:"))
self._proto_sn = QLineEdit()
self._proto_sn.setMaximumWidth(150)
self._proto_sn.textChanged.connect(self._refresh_proto_topics)
row1.addWidget(self._proto_sn)
row1.addStretch()
layout.addLayout(row1)
# 服务器下发 topics
g1 = QGroupBox("服务器下发 Topic → 设备订阅(可发布)")
self._proto_srv_tree = QTreeWidget()
self._proto_srv_tree.setHeaderLabels(["Topic", "说明"])
self._proto_srv_tree.setRootIsDecorated(False)
self._proto_srv_tree.itemDoubleClicked.connect(self._proto_publish_item)
g1l = QVBoxLayout(g1)
g1l.addWidget(self._proto_srv_tree)
layout.addWidget(g1)
# 设备上报 topics
g2 = QGroupBox("设备上报 Topic → 服务器订阅(可发布/订阅)")
self._proto_dev_tree = QTreeWidget()
self._proto_dev_tree.setHeaderLabels(["Topic", "说明"])
self._proto_dev_tree.setRootIsDecorated(False)
self._proto_dev_tree.itemDoubleClicked.connect(self._proto_publish_item)
g2l = QVBoxLayout(g2)
g2l.addWidget(self._proto_dev_tree)
layout.addWidget(g2)
# 发布载荷编辑
g3 = QGroupBox("发布载荷 (JSON)")
g3l = QVBoxLayout(g3)
g3l2 = QHBoxLayout()
g3l2.addWidget(QLabel("Topic:"))
self._proto_pub_topic = QLineEdit()
g3l2.addWidget(self._proto_pub_topic, 1)
g3l.addLayout(g3l2)
self._proto_payload = QPlainTextEdit()
self._proto_payload.setFont(QFont("Consolas", 10))
self._proto_payload.setMaximumBlockCount(1000)
g3l.addWidget(self._proto_payload, 1)
btn_row = QHBoxLayout()
b1 = QPushButton("发布")
b1.clicked.connect(self._proto_publish)
btn_row.addWidget(b1)
btn_row.addStretch()
g3l.addLayout(btn_row)
layout.addWidget(g3)
return w
def _refresh_proto_topics(self):
sn = self._proto_sn.text().strip() or "{sn}"
# 服务器下发 — 单主题
srv = self._proto_srv_tree
srv.clear()
QTreeWidgetItem(srv, [topic_down(sn), "设备订阅此主题接收所有命令"])
# 设备上报 — 单主题
dev = self._proto_dev_tree
dev.clear()
item = QTreeWidgetItem(dev, [topic_up(sn), "设备发布:loop_data / event_report / heartbeat / 命令响应"])
# 子条目列出 cmd 类型
for cmd_name, desc in [
("initialize", "设备上电初始化 (V1.03)"),
("loop_data", "线圈传感数据上报"),
("event_report", "事件上报(有车/无车/故障)"),
("heartbeat", "设备心跳"),
("响应", "各命令查询/设置响应(msg_id 匹配)"),
]:
QTreeWidgetItem(item, [f" cmd={cmd_name}", desc])
def _proto_publish_item(self, item: QTreeWidgetItem, col: int):
# 如果点中的是子条目(无有效 topic),取父条目的 topic
topic = item.text(0)
if item.parent() and not topic.startswith("dld960/"):
topic = item.parent().text(0)
self._proto_pub_topic.setText(topic)
self._proto_payload.clear()
self._notebook.setCurrentWidget(self._proto_payload.parent().parent())
def _proto_publish(self):
topic = self._proto_pub_topic.text()
if not topic or not self._mqtt.connected:
QMessageBox.warning(self, "提示", "请输入 Topic 并连接 Broker")
return
raw = self._proto_payload.toPlainText().strip()
try:
if raw:
payload = json.loads(raw)
else:
payload = build_request(CMD_DEV_INFO_QUERY)
self._mqtt.publish(topic, payload)
self._log_send("[协议]", topic, payload)
except json.JSONDecodeError as e:
QMessageBox.warning(self, "JSON 错误", str(e))
except Exception as e:
QMessageBox.critical(self, "发布失败", f"{type(e).__name__}: {e}")
# ================================================================
# 自定义 Topic 订阅/发布
# ================================================================
def _build_custom_topic_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
# 发布区
g1 = QGroupBox("发布 (Publish)")
g1l = QVBoxLayout(g1)
row1 = QHBoxLayout()
row1.addWidget(QLabel("Topic:"))
self._custom_pub_topic = QLineEdit()
row1.addWidget(self._custom_pub_topic, 1)
g1l.addLayout(row1)
row2 = QHBoxLayout()
row2.addWidget(QLabel("QoS:"))
self._custom_qos = QComboBox()
self._custom_qos.addItems(["0", "1", "2"])
self._custom_qos.setCurrentIndex(1)
row2.addWidget(self._custom_qos)
row2.addStretch()
g1l.addLayout(row2)
self._custom_payload = QPlainTextEdit()
self._custom_payload.setFont(QFont("Consolas", 10))
self._custom_payload.setMaximumBlockCount(2000)
g1l.addWidget(self._custom_payload, 1)
btn_pub = QPushButton("发布")
btn_pub.clicked.connect(self._custom_publish)
g1l.addWidget(btn_pub)
layout.addWidget(g1)
# 订阅区
g2 = QGroupBox("订阅 (Subscribe) / 接收")
g2l = QVBoxLayout(g2)
row3 = QHBoxLayout()
row3.addWidget(QLabel("Topic:"))
self._custom_sub_topic = QLineEdit()
row3.addWidget(self._custom_sub_topic, 1)
row3.addWidget(QLabel("QoS:"))
self._custom_sub_qos = QComboBox()
self._custom_sub_qos.addItems(["0", "1", "2"])
self._custom_sub_qos.setCurrentIndex(1)
row3.addWidget(self._custom_sub_qos)
btn_sub = QPushButton("订阅")
btn_sub.clicked.connect(self._custom_subscribe)
row3.addWidget(btn_sub)
g2l.addLayout(row3)
# 已订阅列表 + 接收消息
self._custom_sub_list = QTreeWidget()
self._custom_sub_list.setHeaderLabels(["已订阅 Topic", "QoS"])
self._custom_sub_list.setRootIsDecorated(False)
g2l.addWidget(self._custom_sub_list)
self._custom_recv = QPlainTextEdit()
self._custom_recv.setReadOnly(True)
self._custom_recv.setFont(QFont("Consolas", 10))
self._custom_recv.setMaximumBlockCount(2000)
g2l.addWidget(self._custom_recv, 1)
row4 = QHBoxLayout()
btn_unsub = QPushButton("取消订阅")
btn_unsub.clicked.connect(self._custom_unsubscribe)
row4.addWidget(btn_unsub)
row4.addStretch()
btn_clear = QPushButton("清空接收")
btn_clear.clicked.connect(self._custom_recv.clear)
row4.addWidget(btn_clear)
g2l.addLayout(row4)
layout.addWidget(g2)
# 保存自定义订阅列表,用于消息分发
self._custom_subs: dict[str, int] = {}
return w
def _custom_publish(self):
if not self._mqtt.connected:
QMessageBox.warning(self, "提示", "请先连接 Broker")
return
topic = self._custom_pub_topic.text()
if not topic:
return
raw = self._custom_payload.toPlainText().strip()
try:
payload = json.loads(raw) if raw else {}
qos = int(self._custom_qos.currentText())
self._mqtt.publish(topic, payload, qos=qos)
self._log_send("[自定义]", topic, payload)
except json.JSONDecodeError as e:
QMessageBox.warning(self, "JSON 错误", str(e))
except Exception as e:
QMessageBox.critical(self, "发布失败", f"{type(e).__name__}: {e}")
self._log(f"[自定义] 发布失败: {e}")
def _custom_subscribe(self):
if not self._mqtt.connected:
QMessageBox.warning(self, "提示", "请先连接 Broker")
return
topic = self._custom_sub_topic.text()
if not topic:
return
try:
qos = int(self._custom_sub_qos.currentText())
self._mqtt.subscribe(topic, qos=qos)
self._custom_subs[topic] = qos
# 更新列表
found = False
for i in range(self._custom_sub_list.topLevelItemCount()):
item = self._custom_sub_list.topLevelItem(i)
if item.text(0) == topic:
item.setText(1, str(qos))
found = True
break
if not found:
self._custom_sub_list.addTopLevelItem(QTreeWidgetItem([topic, str(qos)]))
self._log(f"[订阅] {topic} (QoS={qos})")
except Exception as e:
QMessageBox.critical(self, "订阅失败", f"{type(e).__name__}: {e}")
self._log(f"[订阅] 失败: {e}")
def _custom_unsubscribe(self):
items = self._custom_sub_list.selectedItems()
if not items:
return
for item in items:
topic = item.text(0)
try:
if topic in self._custom_subs:
self._mqtt.unsubscribe(topic)
del self._custom_subs[topic]
idx = self._custom_sub_list.indexOfTopLevelItem(item)
self._custom_sub_list.takeTopLevelItem(idx)
self._log(f"[取消订阅] {topic}")
except Exception as e:
self._log(f"[取消订阅] 失败: {e}")
# ================================================================
# MQTT
# ================================================================
def _toggle_connect(self):
if self._mqtt.connected:
self._mqtt.disconnect()
else:
cfg = BrokerConfig(
host=self._host_edit.text(),
port=int(self._port_edit.text() or "1883"),
username=self._user_edit.text(),
password=self._pass_edit.text(),
)
self._mqtt.connect(cfg)
self._log(f"正在连接 {cfg.host}:{cfg.port} ...")
@Slot(bool, str)
def _on_status(self, connected: bool, msg: str):
if connected:
self._btn_conn.setText("断开")
self._status_label.setText("● 已连接")
self._status_label.setStyleSheet("color: green; font-weight: bold;")
else:
self._btn_conn.setText("连接")
self._status_label.setText("● 未连接")
self._status_label.setStyleSheet("color: red; font-weight: bold;")
self._log(msg)
@Slot(str, str, object)
def _on_message(self, topic: str, dev_serial: str, payload: dict):
cmd = payload.get("cmd", "")
ts = datetime.fromtimestamp(payload.get("ts", 0)).strftime("%H:%M:%S")
# 响应类消息(含 code 字段)
if "code" in payload:
code = payload.get("code", -1)
pmsg = payload.get("msg", "")
data = payload.get("data", {})
self._log_recv(topic, payload, f"{cmd} code={code} {pmsg}")
if code == 0:
if cmd == CMD_DEV_INFO_QUERY:
self._devmgr.update_from_dev_info(dev_serial, data)
self._show_json(data)
elif cmd == CMD_SSC_NET_QUERY:
self._apply_ssc_net_data(data)
elif cmd == CMD_IOT_NET_QUERY:
self._apply_iot_net_data(data)
elif cmd == CMD_IOT_TOPIC_QUERY:
self._apply_iot_topic_data(data)
elif cmd == CMD_LOOP_PARAM_QUERY:
self._show_json({cmd: data})
elif cmd == CMD_REPORT_CONFIG:
self._apply_report_config_data(data)
else:
self._show_json({"error": f"code={code} {pmsg} ({ERROR_MSGS.get(code, '?')})"})
elif cmd == CMD_LOOP_DATA:
data = payload.get("data", {})
self._devmgr.update_loop_data(dev_serial, data)
chs = len(data.get("channels", []))
self._log_recv(topic, payload, f"loop_data ({chs}ch)")
self._append_text(self._loop_text, json.dumps(payload, indent=2, ensure_ascii=False))
elif cmd == CMD_EVENT_REPORT:
data = payload.get("data", {})
self._devmgr.update_event(dev_serial, data)
evt_count = len(data.get("events", []))
self._log_recv(topic, payload, f"event ({evt_count} events)")
self._append_text(self._event_text, json.dumps(payload, indent=2, ensure_ascii=False))
elif cmd == CMD_HEARTBEAT:
data = payload.get("data", {})
self._devmgr.update_heartbeat(dev_serial, data)
self._log_recv(topic, payload, f"heartbeat uptime={data.get('uptime', 0)}s")
elif cmd == CMD_INITIALIZE:
# V1.03: 设备上线初始化
data = payload.get("data", {})
dev_sn = data.get("dev_serial", dev_serial)
extra = data.get("extra_info", {})
self._devmgr.mark_online(dev_sn, extra)
self._log_recv(topic, payload, f"initialize sn={dev_sn} ver={extra.get('version','?')}")
self._show_json({"online": dev_sn, "info": extra})
# 旧协议 / 无 cmd 字段 的消息(如 Initialize
elif "Method" in payload:
self._log_recv(topic, payload, f"Method={payload.get('Method', '?')}")
else:
self._log_recv(topic, payload)
# 自定义订阅消息 → 显示到自定义 Topic 接收区
for sub_topic in self._custom_subs:
if mqtt.topic_matches_sub(sub_topic, topic):
self._append_custom_recv(topic, payload)
break
def _append_custom_recv(self, topic: str, payload: dict):
ts = datetime.now().strftime("%H:%M:%S")
text = f"--- {ts} ---\nTopic: {topic}\n{json.dumps(payload, indent=2, ensure_ascii=False)}\n"
self._custom_recv.appendPlainText(text)
self._custom_recv.moveCursor(QTextCursor.End)
# ================================================================
# 设备操作
# ================================================================
def _selected_dev(self) -> Optional[str]:
items = self._dev_tree.selectedItems()
return items[0].text(0).lstrip("●○ ") if items else None
def _require_dev(self) -> Optional[str]:
sn = self._selected_dev()
if not sn:
QMessageBox.warning(self, "提示", "请先选择一个设备")
return None
if not self._mqtt.connected:
QMessageBox.warning(self, "提示", "请先连接 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:
mid = self._mqtt.send_command(sn, cmd, data)
from dbn_mqtt_tool.protocol import build_request, get_topic_for_cmd
msg = build_request(cmd, data)
topic = get_topic_for_cmd(cmd, sn)
self._log_send(f"[命令] {sn}", topic, msg)
except Exception as e:
QMessageBox.critical(self, "发送失败", 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._edit_ssc_ip.text(),
subnet_mask=self._edit_ssc_mask.text(),
route_ip=self._edit_ssc_gw.text(),
lssc_ip=self._edit_ssc_lssc.text(),
dns=self._edit_ssc_dns.text(),
port=int(self._edit_ssc_port.text() 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._edit_iot_host.text(),
port=int(self._edit_iot_port.text() or "1883"),
client_id=self._edit_iot_cid.text(),
username=self._edit_iot_user.text(),
password=self._edit_iot_pass.text(),
))
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._chk_cid.isChecked(),
topic_pub=self._edit_topic_pub.text(),
topic_sub=self._edit_topic_sub.text(),
))
def _apply_ssc_net_data(self, data: dict):
self._edit_ssc_ip.setText(str(data.get("dev_ip", "")))
self._edit_ssc_mask.setText(str(data.get("subnet_mask", "")))
self._edit_ssc_gw.setText(str(data.get("route_ip", "")))
self._edit_ssc_lssc.setText(str(data.get("lssc_ip", "")))
self._edit_ssc_dns.setText(str(data.get("dns", "")))
self._edit_ssc_port.setText(str(data.get("port", "")))
def _apply_iot_net_data(self, data: dict):
self._edit_iot_host.setText(str(data.get("host", "")))
self._edit_iot_port.setText(str(data.get("port", "")))
self._edit_iot_cid.setText(str(data.get("client_id", "")))
self._edit_iot_user.setText(str(data.get("username", "")))
self._edit_iot_pass.setText(str(data.get("password", "")))
def _apply_iot_topic_data(self, data: dict):
self._chk_cid.setChecked(bool(data.get("client_id_enable", True)))
self._edit_topic_pub.setText(str(data.get("topic_pub", "")))
self._edit_topic_sub.setText(str(data.get("topic_sub", "")))
def _apply_report_config_data(self, data: dict):
self._edit_report_sensor_type.setValue(int(data.get("sensor_type", 12)))
self._edit_report_interval.setValue(int(data.get("interval", 5)))
self._edit_report_timeout.setValue(int(data.get("timeout", 0)))
self._chk_report_enable.setChecked(bool(data.get("enable", True)))
self._chk_report_once.setChecked(bool(data.get("once", False)))
self._chk_report_env.setChecked(bool(data.get("env_eval", False)))
self._chk_report_ack.setChecked(bool(data.get("ack_required", False)))
def _do_pwd_verify(self):
pwd = self._edit_pwd.text()
if not pwd:
QMessageBox.warning(self, "提示", "请输入密码")
return
self._send_cmd(CMD_PWD_VERIFY, data_pwd_verify(pwd))
def _do_pwd_set(self):
old = self._edit_pwd.text()
new = self._edit_new_pwd.text()
if not old or not new:
QMessageBox.warning(self, "提示", "请输入旧密码和新密码")
return
self._send_cmd(CMD_PWD_SET, data_pwd_set(old, new))
def _do_factory_reset(self):
r = QMessageBox.question(self, "确认", "确定要恢复出厂设置吗?\n此操作不可撤销!",
QMessageBox.Yes | QMessageBox.No)
if r == QMessageBox.Yes:
self._send_cmd(CMD_FACTORY_RESET)
def _do_device_reset(self):
r = QMessageBox.question(self, "确认", "确定要复位设备吗?设备将重启。",
QMessageBox.Yes | QMessageBox.No)
if r == QMessageBox.Yes:
self._send_cmd(CMD_DEVICE_RESET)
def _query_report_config(self):
self._send_cmd(CMD_REPORT_CONFIG)
def _set_report_config(self):
self._send_cmd(CMD_REPORT_CONFIG, data_report_config(
sensor_type=self._edit_report_sensor_type.value(),
enable=self._chk_report_enable.isChecked(),
once=self._chk_report_once.isChecked(),
env_eval=self._chk_report_env.isChecked(),
interval=self._edit_report_interval.value(),
ack_required=self._chk_report_ack.isChecked(),
timeout=self._edit_report_timeout.value(),
))
# ================================================================
# UI 更新
# ================================================================
@Slot()
def _refresh_devices(self):
tree = self._dev_tree
existing = {}
for i in range(tree.topLevelItemCount()):
item = tree.topLevelItem(i)
sn = item.text(0).lstrip("●○ ")
existing[sn] = item
for sn, dev in self._devmgr.devices.items():
status = "●" if dev.online else "○"
model = dev.model or "?"
if sn in existing:
existing[sn].setText(0, f"{status} {sn}")
existing[sn].setText(1, model)
del existing[sn]
else:
item = QTreeWidgetItem([f"{status} {sn}", model])
tree.addTopLevelItem(item)
for sn, item in existing.items():
idx = tree.indexOfTopLevelItem(item)
tree.takeTopLevelItem(idx)
def _on_dev_select(self):
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_json(info)
def _show_json(self, data: dict):
self._info_text.setPlainText(json.dumps(data, indent=2, ensure_ascii=False))
def _append_text(self, widget: QTextEdit, text: str):
ts = datetime.now().strftime("%H:%M:%S")
widget.append(f"--- {ts} ---\n{text}\n")
widget.moveCursor(QTextCursor.End)
def _log(self, msg: str):
ts = datetime.now().strftime("%H:%M:%S")
self._log_text.append(f"[{ts}] {msg}")
self._log_text.moveCursor(QTextCursor.End)
# ---- 详细收发日志 ----
def _log_send(self, label: str, topic: str, payload: dict):
"""记录发送消息: 标签 + topic + 精简 payload"""
payload_str = json.dumps(payload, ensure_ascii=False)
if len(payload_str) > 200:
payload_str = payload_str[:200] + "..."
self._log(f"{label}{topic}")
self._log(f" PASTE: {payload_str}")
def _log_recv(self, topic: str, payload: dict, summary: str = ""):
"""记录接收消息: topic + summary + 精简 payload"""
payload_str = json.dumps(payload, ensure_ascii=False)
if len(payload_str) > 300:
payload_str = payload_str[:300] + "..."
self._log(f"← {topic}{' ' + summary if summary else ''}")
self._log(f" RECV: {payload_str}")
def main():
app = QApplication(sys.argv)
app.setStyle("Fusion")
win = MainWindow()
win.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()