fix: MqttClient 重构为 QObject — publish 通过 Qt 信号排队避免 C 层崩溃

问题: paho-mqtt publish() C 扩展在 Windows 上从 GUI 线程调用时静默崩溃。

修复:
- MqttClient 改为 QObject 子类
- publish() 通过 Qt Signal(Qt.QueuedConnection) 排队,_do_publish()
  始终在主线程事件循环中执行 paho 调用
- 状态/消息通知改用 Qt Signal 跨线程传递
- 新增 subscribe()/unsubscribe() 公开 API,消除 main.py 直接
  访问 _client 的脆弱代码
- 移除 MainWindow 中已不再需要的中间信号 _mqtt_status/_mqtt_msg
This commit is contained in:
wangfq
2026-07-07 14:25:49 +08:00
parent 6eb93637c7
commit baa82c895d
2 changed files with 50 additions and 41 deletions
+45 -30
View File
@@ -1,7 +1,6 @@
""" """
MQTT 客户端封装 MQTT 客户端封装
""" """
import json import json
import time import time
import threading import threading
@@ -9,6 +8,7 @@ from typing import Callable, Optional
from dataclasses import dataclass from dataclasses import dataclass
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
from PySide6.QtCore import QObject, Signal, Slot, Qt
from .protocol import parse_topic_dev_serial from .protocol import parse_topic_dev_serial
@@ -23,67 +23,64 @@ class BrokerConfig:
keepalive: int = 60 keepalive: int = 60
class MqttClient: class MqttClient(QObject):
"""DLD960 IoT MQTT 客户端""" """DLD960 IoT MQTT 客户端 — QObject 基类确保信号线程安全"""
# 供外部监听
_status_notify = Signal(bool, str) # connected, message
_msg_notify = Signal(str, str, object) # topic, dev_serial, payload_dict
# 内部线程安全的 publish 队列
_publish_sig = Signal(str, str, int) # topic, json_data, qos
def __init__(self): def __init__(self):
super().__init__()
self._client: Optional[mqtt.Client] = None self._client: Optional[mqtt.Client] = None
self._config = BrokerConfig() self._config = BrokerConfig()
self._connected = False self._connected = False
self._lock = threading.Lock() self._lock = threading.Lock()
self._message_callbacks: list[Callable[[str, str, dict], None]] = []
self._status_callbacks: list[Callable[[bool, str], None]] = [] # 信号→槽: publish 通过排队连接确保在主线程的 paho context 执行
self._publish_sig.connect(self._do_publish, Qt.QueuedConnection)
@property @property
def connected(self) -> bool: def connected(self) -> bool:
return self._connected return self._connected
# ---- 公开回调 (兼容旧接口) ----
def on_status_change(self, callback: Callable[[bool, str], None]): def on_status_change(self, callback: Callable[[bool, str], None]):
self._status_callbacks.append(callback) self._status_notify.connect(callback)
def on_message(self, callback: Callable[[str, str, dict], None]): def on_message(self, callback: Callable[[str, str, dict], None]):
"""回调参数: (topic, dev_serial, payload_dict)""" """回调参数: (topic, dev_serial, payload_dict)"""
self._message_callbacks.append(callback) self._msg_notify.connect(callback)
def _notify_status(self, connected: bool, msg: str): # ---- MQTT callbacks (paho 网络线程) ----
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): def _on_connect(self, client, userdata, flags, reason_code, properties=None):
rc = reason_code if isinstance(reason_code, int) else reason_code.value rc = reason_code if isinstance(reason_code, int) else reason_code.value
if rc == 0: if rc == 0:
self._connected = True self._connected = True
self._notify_status(True, "已连接到 Broker") self._status_notify.emit(True, "已连接到 Broker")
from .protocol import TOPIC_ALL_DEVICE_UP from .protocol import TOPIC_ALL_DEVICE_UP
client.subscribe(TOPIC_ALL_DEVICE_UP, qos=1) client.subscribe(TOPIC_ALL_DEVICE_UP, qos=1)
else: else:
self._connected = False self._connected = False
self._notify_status(False, f"连接失败 (rc={rc})") self._status_notify.emit(False, f"连接失败 (rc={rc})")
def _on_disconnect(self, client, userdata, *args): def _on_disconnect(self, client, userdata, *args):
self._connected = False self._connected = False
self._notify_status(False, "已断开连接") self._status_notify.emit(False, "已断开连接")
def _on_message(self, client, userdata, msg: mqtt.MQTTMessage): def _on_message(self, client, userdata, msg: mqtt.MQTTMessage):
try: try:
payload = json.loads(msg.payload.decode("utf-8")) payload = json.loads(msg.payload.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError): except (json.JSONDecodeError, UnicodeDecodeError):
payload = {"_raw": msg.payload.hex()} payload = {"_raw": msg.payload.hex()}
self._notify_message(msg.topic, payload) dev_serial = parse_topic_dev_serial(msg.topic) or ""
self._msg_notify.emit(msg.topic, dev_serial, payload)
# ---- Public API ---- # ---- Public API ----
@@ -96,7 +93,6 @@ class MqttClient:
client_id = self._config.client_id or f"dbn_mqtt_tool_{int(time.time())}" client_id = self._config.client_id or f"dbn_mqtt_tool_{int(time.time())}"
# MQTT 5.0 用 CallbackAPIVersion.VERSION2
self._client = mqtt.Client( self._client = mqtt.Client(
client_id=client_id, client_id=client_id,
protocol=mqtt.MQTTv311, protocol=mqtt.MQTTv311,
@@ -120,11 +116,30 @@ class MqttClient:
self._connected = False self._connected = False
def publish(self, topic: str, payload: dict, qos: int = 1): def publish(self, topic: str, payload: dict, qos: int = 1):
"""发送 JSON 消息到指定 topic""" """线程安全的发布 — 通过 Qt 信号排队到主线程执行"""
if not self._client or not self._connected: if not self._client or not self._connected:
raise ConnectionError("MQTT 未连接") raise ConnectionError("MQTT 未连接")
data = json.dumps(payload, ensure_ascii=False) data = json.dumps(payload, ensure_ascii=False)
self._client.publish(topic, data, qos=qos) self._publish_sig.emit(topic, data, qos)
def subscribe(self, topic: str, qos: int = 1):
"""订阅主题"""
if self._client:
self._client.subscribe(topic, qos=qos)
def unsubscribe(self, topic: str):
"""取消订阅"""
if self._client:
self._client.unsubscribe(topic)
@Slot(str, str, int)
def _do_publish(self, topic: str, data: str, qos: int):
"""实际的 paho publish 调用 — 始终在主线程执行"""
if self._client and self._connected:
try:
self._client.publish(topic, data, qos=qos)
except Exception as e:
print(f"[MqttClient] publish error: {e}")
def send_command(self, dev_serial: str, cmd: str, data: Optional[dict] = None, qos: int = 1) -> int: def send_command(self, dev_serial: str, cmd: str, data: Optional[dict] = None, qos: int = 1) -> int:
"""向设备发送命令,返回 msg_id""" """向设备发送命令,返回 msg_id"""
+5 -11
View File
@@ -39,8 +39,6 @@ from dbn_mqtt_tool.protocol import (
) )
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
_mqtt_status = Signal(bool, str)
_mqtt_msg = Signal(str, str, object)
_devices_changed = Signal() _devices_changed = Signal()
def __init__(self): def __init__(self):
@@ -52,12 +50,10 @@ class MainWindow(QMainWindow):
self._mqtt = MqttClient() self._mqtt = MqttClient()
self._devmgr = DeviceManager() self._devmgr = DeviceManager()
self._mqtt.on_status_change(lambda c, m: self._mqtt_status.emit(c, m)) self._mqtt._status_notify.connect(self._on_status)
self._mqtt.on_message(lambda t, s, p: self._mqtt_msg.emit(t, s, p)) self._mqtt._msg_notify.connect(self._on_message)
self._devmgr.on_change(lambda: self._devices_changed.emit()) 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._devices_changed.connect(self._refresh_devices)
self._build_ui() self._build_ui()
@@ -695,7 +691,7 @@ class MainWindow(QMainWindow):
self._log(f"[自定义] 发布失败: {e}") self._log(f"[自定义] 发布失败: {e}")
def _custom_subscribe(self): def _custom_subscribe(self):
if not self._mqtt.connected or not self._mqtt._client: if not self._mqtt.connected:
QMessageBox.warning(self, "提示", "请先连接 Broker") QMessageBox.warning(self, "提示", "请先连接 Broker")
return return
topic = self._custom_sub_topic.text() topic = self._custom_sub_topic.text()
@@ -703,7 +699,7 @@ class MainWindow(QMainWindow):
return return
try: try:
qos = int(self._custom_sub_qos.currentText()) qos = int(self._custom_sub_qos.currentText())
self._mqtt._client.subscribe(topic, qos=qos) self._mqtt.subscribe(topic, qos=qos)
self._custom_subs[topic] = qos self._custom_subs[topic] = qos
# 更新列表 # 更新列表
found = False found = False
@@ -724,13 +720,11 @@ class MainWindow(QMainWindow):
items = self._custom_sub_list.selectedItems() items = self._custom_sub_list.selectedItems()
if not items: if not items:
return return
if not self._mqtt._client:
return
for item in items: for item in items:
topic = item.text(0) topic = item.text(0)
try: try:
if topic in self._custom_subs: if topic in self._custom_subs:
self._mqtt._client.unsubscribe(topic) self._mqtt.unsubscribe(topic)
del self._custom_subs[topic] del self._custom_subs[topic]
idx = self._custom_sub_list.indexOfTopLevelItem(item) idx = self._custom_sub_list.indexOfTopLevelItem(item)
self._custom_sub_list.takeTopLevelItem(idx) self._custom_sub_list.takeTopLevelItem(idx)