mqtt_client.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  1. import paho.mqtt.client as mqtt
  2. import threading
  3. import json
  4. import time
  5. import logging
  6. import ssl
  7. from dataclasses import dataclass, field
  8. from typing import Dict, List, Optional, Tuple, Any
  9. # 配置日志
  10. logging.basicConfig(
  11. level=logging.INFO,
  12. format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
  13. )
  14. logger = logging.getLogger('mqtt_client')
  15. @dataclass
  16. class MQTTConfig:
  17. """MQTT配置数据类"""
  18. broker: str
  19. port: int = 1883
  20. username: str = ""
  21. password: str = ""
  22. client_id: str = ""
  23. keepalive: int = 60
  24. qos: int = 0
  25. # TLS/SSL 配置
  26. use_tls: bool = False
  27. tls_version: int = ssl.PROTOCOL_TLSv1_2
  28. ca_certs: Optional[str] = None
  29. certfile: Optional[str] = None
  30. keyfile: Optional[str] = None
  31. # 重连配置
  32. reconnect_delay: float = 1.0
  33. reconnect_delay_max: float = 120.0
  34. reconnect_exponential_backoff: bool = True
  35. max_reconnect_attempts: int = 0 # 0 表示无限尝试
  36. # 消息配置
  37. will_topic: Optional[str] = None
  38. will_payload: Optional[str] = None
  39. will_qos: int = 0
  40. will_retain: bool = False
  41. class MQTTClient:
  42. """MQTT客户端封装类,提供高级MQTT通信功能"""
  43. def __init__(self):
  44. self.client = None
  45. self.is_connected = False
  46. self.config = None
  47. self.topics = [] # 存储主题和QoS的元组列表
  48. self.data_callback = None
  49. self.status_callback = None
  50. self.error_callback = None
  51. self.connected_event = threading.Event()
  52. self.lock = threading.RLock()
  53. self.reconnect_attempts = 0
  54. self.last_reconnect_time = 0
  55. self.last_will_set = False
  56. def connect(self, broker: str, port: int = 1883, username: str = "",
  57. password: str = "", client_id: str = "", use_tls: bool = False,
  58. qos: int = 0, **kwargs) -> Tuple[bool, str]:
  59. """
  60. 连接到MQTT服务器
  61. Args:
  62. broker: MQTT服务器地址
  63. port: MQTT服务器端口
  64. username: 用户名
  65. password: 密码
  66. client_id: 客户端ID,为空时自动生成
  67. use_tls: 是否使用TLS加密
  68. qos: 默认QoS级别
  69. **kwargs: 其他配置参数
  70. Returns:
  71. tuple: (是否成功, 消息)
  72. """
  73. try:
  74. # 构建配置
  75. config = MQTTConfig(
  76. broker=broker,
  77. port=port,
  78. username=username,
  79. password=password,
  80. client_id=client_id or f"serial_mqtt_gateway_{time.time()}",
  81. use_tls=use_tls,
  82. qos=qos,
  83. **kwargs
  84. )
  85. with self.lock:
  86. # 断开现有连接
  87. if self.is_connected:
  88. self.disconnect()
  89. logger.info(f"尝试连接MQTT服务器: {broker}:{port}")
  90. # 创建MQTT客户端
  91. self.client = mqtt.Client(
  92. client_id=config.client_id,
  93. clean_session=True,
  94. userdata=None
  95. )
  96. # 设置回调函数
  97. self.client.on_connect = self._on_connect
  98. self.client.on_disconnect = self._on_disconnect
  99. self.client.on_message = self._on_message
  100. self.client.on_publish = self._on_publish
  101. self.client.on_subscribe = self._on_subscribe
  102. # 设置重连参数(兼容 paho-mqtt 1.x 与 2.x)
  103. try:
  104. self.client.reconnect_delay_set(
  105. min_delay=config.reconnect_delay,
  106. max_delay=config.reconnect_delay_max,
  107. exponential_backoff=config.reconnect_exponential_backoff
  108. )
  109. except TypeError:
  110. # paho-mqtt 1.x 的 reconnect_delay_set 不支持 exponential_backoff
  111. self.client.reconnect_delay_set(
  112. min_delay=config.reconnect_delay,
  113. max_delay=config.reconnect_delay_max
  114. )
  115. # 设置用户名密码
  116. if config.username and config.password:
  117. self.client.username_pw_set(config.username, config.password)
  118. logger.debug(f"已设置MQTT用户名密码认证")
  119. # 配置TLS
  120. if config.use_tls:
  121. try:
  122. self.client.tls_set(
  123. ca_certs=config.ca_certs,
  124. certfile=config.certfile,
  125. keyfile=config.keyfile,
  126. tls_version=config.tls_version
  127. )
  128. logger.debug(f"已配置MQTT TLS连接")
  129. except Exception as e:
  130. error_msg = f"配置TLS失败: {str(e)}"
  131. logger.error(error_msg)
  132. return False, error_msg
  133. # 设置遗嘱消息
  134. if config.will_topic:
  135. will_payload = config.will_payload or "{\"status\":\"offline\"}"
  136. self.client.will_set(
  137. topic=config.will_topic,
  138. payload=will_payload,
  139. qos=config.will_qos,
  140. retain=config.will_retain
  141. )
  142. self.last_will_set = True
  143. logger.debug(f"已设置MQTT遗嘱消息: {config.will_topic}")
  144. # 连接到服务器
  145. self.client.connect(
  146. host=config.broker,
  147. port=config.port,
  148. keepalive=config.keepalive
  149. )
  150. # 启动客户端循环
  151. self.client.loop_start()
  152. # 保存配置
  153. self.config = config
  154. # 等待连接成功或超时
  155. connected = self.connected_event.wait(timeout=10)
  156. if connected:
  157. logger.info(f"已连接到MQTT服务器 {broker}:{port}")
  158. self.reconnect_attempts = 0
  159. self.last_reconnect_time = time.time()
  160. if self.status_callback:
  161. self.status_callback(True)
  162. # 重新订阅之前的主题
  163. if self.topics:
  164. self._resubscribe_topics()
  165. return True, f"已连接到MQTT服务器 {broker}:{port}"
  166. else:
  167. error_msg = "连接MQTT服务器超时"
  168. logger.error(error_msg)
  169. self.client.loop_stop()
  170. self.client = None
  171. if self.status_callback:
  172. self.status_callback(False)
  173. return False, error_msg
  174. except Exception as e:
  175. error_msg = f"连接失败: {str(e)}"
  176. logger.error(error_msg)
  177. if self.status_callback:
  178. self.status_callback(False)
  179. if self.error_callback:
  180. self.error_callback(error_msg)
  181. return False, error_msg
  182. def disconnect(self) -> Tuple[bool, str]:
  183. """
  184. 断开MQTT连接
  185. Returns:
  186. tuple: (是否成功, 消息)
  187. """
  188. try:
  189. with self.lock:
  190. logger.info("断开MQTT连接")
  191. if self.client:
  192. # 发布在线状态(如果设置了遗嘱消息)
  193. if self.last_will_set and self.is_connected:
  194. try:
  195. self.client.publish(
  196. topic=self.config.will_topic,
  197. payload="{\"status\":\"online\"}",
  198. qos=self.config.will_qos,
  199. retain=self.config.will_retain
  200. )
  201. time.sleep(0.1) # 给发布消息一些时间
  202. except Exception as e:
  203. logger.warning(f"发布离线状态失败: {str(e)}")
  204. # 停止循环并断开连接
  205. self.client.loop_stop()
  206. try:
  207. self.client.disconnect()
  208. except Exception as e:
  209. logger.error(f"断开连接时出错: {str(e)}")
  210. finally:
  211. self.client = None
  212. self.is_connected = False
  213. self.connected_event.clear()
  214. if self.status_callback:
  215. self.status_callback(False)
  216. return True, "已断开MQTT连接"
  217. except Exception as e:
  218. error_msg = f"断开连接失败: {str(e)}"
  219. logger.error(error_msg)
  220. return False, error_msg
  221. def subscribe(self, topics, qos: Optional[int] = None) -> Tuple[bool, str]:
  222. """
  223. 订阅主题
  224. Args:
  225. topics: 主题字符串或列表
  226. qos: QoS级别,如果为None则使用配置中的默认值
  227. Returns:
  228. tuple: (是否成功, 消息)
  229. """
  230. try:
  231. with self.lock:
  232. if not self.client or not self.is_connected:
  233. return False, "MQTT未连接"
  234. # 确定QoS级别
  235. qos_level = qos if qos is not None else (self.config.qos if self.config else 0)
  236. # 格式化主题列表
  237. if isinstance(topics, str):
  238. topic_list = [(topics, qos_level)]
  239. topic_names = [topics]
  240. else:
  241. topic_list = [(topic, qos_level) for topic in topics]
  242. topic_names = topics
  243. # 订阅主题
  244. result, _ = self.client.subscribe(topic_list)
  245. if result == mqtt.MQTT_ERR_SUCCESS:
  246. # 更新本地主题列表
  247. self.topics = topic_list
  248. logger.info(f"已订阅主题: {', '.join(topic_names)}, QoS: {qos_level}")
  249. return True, f"已订阅主题: {', '.join(topic_names)}"
  250. else:
  251. error_msg = f"订阅失败: {mqtt.error_string(result)}"
  252. logger.error(error_msg)
  253. return False, error_msg
  254. except Exception as e:
  255. error_msg = f"订阅失败: {str(e)}"
  256. logger.error(error_msg)
  257. return False, error_msg
  258. def publish(self, topic: str, message: Any, qos: Optional[int] = None,
  259. retain: bool = False) -> Tuple[bool, str]:
  260. """
  261. 发布消息
  262. Args:
  263. topic: 发布主题
  264. message: 消息内容(自动转换为JSON字符串)
  265. qos: QoS级别,如果为None则使用配置中的默认值
  266. retain: 是否为保留消息
  267. Returns:
  268. tuple: (是否成功, 消息)
  269. """
  270. try:
  271. with self.lock:
  272. if not self.client or not self.is_connected:
  273. return False, "MQTT未连接"
  274. # 确定QoS级别
  275. qos_level = qos if qos is not None else (self.config.qos if self.config else 0)
  276. # 序列化消息
  277. if isinstance(message, (dict, list)):
  278. payload = json.dumps(message)
  279. else:
  280. payload = str(message)
  281. # 发布消息
  282. result = self.client.publish(
  283. topic=topic,
  284. payload=payload,
  285. qos=qos_level,
  286. retain=retain
  287. )
  288. # 检查发布是否成功
  289. if result.rc == mqtt.MQTT_ERR_SUCCESS:
  290. logger.debug(f"消息已发布到主题 {topic}: {payload[:50]}...")
  291. # 上行消息也转发到前端 MQTT 数据监控页面显示
  292. if self.data_callback:
  293. try:
  294. self.data_callback({
  295. 'topic': topic,
  296. 'payload': payload,
  297. 'qos': qos_level,
  298. 'retain': retain,
  299. 'timestamp': time.time(),
  300. 'direction': 'uplink'
  301. })
  302. except Exception as e:
  303. logger.debug(f"转发上行消息到数据回调失败: {e}")
  304. return True, f"消息已发布到主题 {topic}"
  305. else:
  306. error_msg = f"发布失败: {mqtt.error_string(result.rc)}"
  307. logger.error(error_msg)
  308. return False, error_msg
  309. except Exception as e:
  310. error_msg = f"发布失败: {str(e)}"
  311. logger.error(error_msg)
  312. return False, error_msg
  313. def unsubscribe(self, topics) -> Tuple[bool, str]:
  314. """
  315. 取消订阅主题
  316. Args:
  317. topics: 主题字符串或列表
  318. Returns:
  319. tuple: (是否成功, 消息)
  320. """
  321. try:
  322. with self.lock:
  323. if not self.client or not self.is_connected:
  324. return False, "MQTT未连接"
  325. # 格式化主题列表
  326. if isinstance(topics, str):
  327. topic_list = [topics]
  328. else:
  329. topic_list = topics
  330. # 取消订阅
  331. result = self.client.unsubscribe(topic_list)
  332. if result.rc == mqtt.MQTT_ERR_SUCCESS:
  333. # 更新本地主题列表
  334. self.topics = [(t, q) for t, q in self.topics if t not in topic_list]
  335. logger.info(f"已取消订阅主题: {', '.join(topic_list)}")
  336. return True, f"已取消订阅主题: {', '.join(topic_list)}"
  337. else:
  338. error_msg = f"取消订阅失败: {mqtt.error_string(result.rc)}"
  339. logger.error(error_msg)
  340. return False, error_msg
  341. except Exception as e:
  342. error_msg = f"取消订阅失败: {str(e)}"
  343. logger.error(error_msg)
  344. return False, error_msg
  345. def _resubscribe_topics(self):
  346. """重新订阅所有已保存的主题"""
  347. if self.topics:
  348. try:
  349. topic_names = [topic for topic, _ in self.topics]
  350. logger.info(f"重新订阅之前的主题: {', '.join(topic_names)}")
  351. self.client.subscribe(self.topics)
  352. except Exception as e:
  353. logger.error(f"重新订阅主题失败: {str(e)}")
  354. def _on_connect(self, client, userdata, flags, rc):
  355. """连接回调函数"""
  356. if rc == 0:
  357. self.is_connected = True
  358. self.connected_event.set()
  359. logger.info(f"成功连接到MQTT服务器: {self.config.broker}:{self.config.port}")
  360. # 重置重连计数
  361. self.reconnect_attempts = 0
  362. self.last_reconnect_time = time.time()
  363. else:
  364. self.is_connected = False
  365. self.connected_event.clear()
  366. error_msg = f"连接MQTT服务器失败: {mqtt.connack_string(rc)} (代码: {rc})"
  367. logger.error(error_msg)
  368. if self.error_callback:
  369. self.error_callback(error_msg)
  370. def _on_disconnect(self, client, userdata, rc):
  371. """断开连接回调函数"""
  372. self.is_connected = False
  373. self.connected_event.clear()
  374. # 区分主动断开和意外断开
  375. if rc != 0:
  376. error_msg = f"意外断开MQTT连接 (代码: {rc})"
  377. logger.warning(error_msg)
  378. # 处理重连逻辑
  379. self._handle_reconnect()
  380. if self.error_callback:
  381. self.error_callback(error_msg)
  382. else:
  383. logger.info("主动断开MQTT连接")
  384. if self.status_callback:
  385. self.status_callback(False)
  386. def _on_message(self, client, userdata, msg):
  387. """消息接收回调函数"""
  388. try:
  389. # 尝试多种编码解码
  390. encodings = ['utf-8', 'latin-1', 'ascii']
  391. payload = None
  392. for encoding in encodings:
  393. try:
  394. payload = msg.payload.decode(encoding)
  395. break
  396. except UnicodeDecodeError:
  397. continue
  398. # 如果都失败,转为十六进制
  399. if payload is None:
  400. payload = msg.payload.hex()
  401. logger.warning(f"收到无法解码的二进制消息,已转为十六进制: {payload[:50]}...")
  402. # 构建消息数据结构
  403. data = {
  404. 'topic': msg.topic,
  405. 'payload': payload,
  406. 'qos': msg.qos,
  407. 'retain': msg.retain,
  408. 'timestamp': time.time()
  409. }
  410. logger.debug(f"收到MQTT消息: 主题={msg.topic}, 长度={len(msg.payload)}字节")
  411. if self.data_callback:
  412. self.data_callback(data)
  413. except Exception as e:
  414. error_msg = f"处理MQTT消息错误: {str(e)}"
  415. logger.error(error_msg)
  416. if self.error_callback:
  417. self.error_callback(error_msg)
  418. def _on_publish(self, client, userdata, mid):
  419. """发布回调函数"""
  420. logger.debug(f"消息发布成功,消息ID: {mid}")
  421. def _on_subscribe(self, client, userdata, mid, granted_qos):
  422. """订阅回调函数"""
  423. logger.debug(f"主题订阅成功,消息ID: {mid}, 授权QoS: {granted_qos}")
  424. def _handle_reconnect(self):
  425. """处理重连逻辑"""
  426. if not self.config or self.config.max_reconnect_attempts == 0:
  427. # 如果未设置最大重连次数或为0,则无限重连
  428. return
  429. self.reconnect_attempts += 1
  430. if self.reconnect_attempts > self.config.max_reconnect_attempts:
  431. logger.error(f"已达到最大重连次数 ({self.config.max_reconnect_attempts}),停止重连")
  432. # 可以在这里调用断开连接或通知上层
  433. self.disconnect()
  434. def set_data_callback(self, callback):
  435. """设置数据接收回调函数"""
  436. self.data_callback = callback
  437. def set_status_callback(self, callback):
  438. """设置状态变化回调函数"""
  439. self.status_callback = callback
  440. def set_error_callback(self, callback):
  441. """设置错误回调函数"""
  442. self.error_callback = callback
  443. def get_status(self) -> Dict[str, Any]:
  444. """
  445. 获取当前连接状态
  446. Returns:
  447. dict: 包含连接状态和详细信息
  448. """
  449. with self.lock:
  450. status = {
  451. 'connected': self.is_connected,
  452. 'broker': self.config.broker if self.config else None,
  453. 'port': self.config.port if self.config else None,
  454. 'client_id': self.config.client_id if self.config else None,
  455. 'topics': [topic for topic, _ in self.topics],
  456. 'reconnect_attempts': self.reconnect_attempts,
  457. 'last_reconnect_time': self.last_reconnect_time
  458. }
  459. return status
  460. def get_config(self) -> Optional[MQTTConfig]:
  461. """获取当前MQTT配置"""
  462. return self.config
  463. # 创建全局MQTT实例
  464. global_mqtt = MQTTClient()