mqttxx 3.1.2__py3-none-any.whl → 3.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mqttxx/__init__.py +2 -5
- mqttxx/client.py +144 -233
- mqttxx/events.py +45 -26
- mqttxx/protocol.py +45 -149
- mqttxx/rpc.py +48 -29
- {mqttxx-3.1.2.dist-info → mqttxx-3.2.1.dist-info}/METADATA +74 -53
- mqttxx-3.2.1.dist-info/RECORD +12 -0
- mqttxx/conventions.py +0 -148
- mqttxx-3.1.2.dist-info/RECORD +0 -13
- {mqttxx-3.1.2.dist-info → mqttxx-3.2.1.dist-info}/LICENSE +0 -0
- {mqttxx-3.1.2.dist-info → mqttxx-3.2.1.dist-info}/WHEEL +0 -0
- {mqttxx-3.1.2.dist-info → mqttxx-3.2.1.dist-info}/top_level.txt +0 -0
mqttxx/__init__.py
CHANGED
|
@@ -9,15 +9,14 @@
|
|
|
9
9
|
- 异常系统: 统一错误码和异常层次
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
-
__version__ = "3.
|
|
12
|
+
__version__ = "3.2.0"
|
|
13
13
|
__author__ = "MQTTX Team"
|
|
14
14
|
|
|
15
15
|
# 核心客户端
|
|
16
|
-
from .client import MQTTClient
|
|
16
|
+
from .client import MQTTClient
|
|
17
17
|
|
|
18
18
|
# RPC 管理器
|
|
19
19
|
from .rpc import RPCManager
|
|
20
|
-
from .conventions import ConventionalRPCManager
|
|
21
20
|
|
|
22
21
|
# Event Channel 管理器
|
|
23
22
|
from .events import EventChannelManager, EventMessage
|
|
@@ -58,10 +57,8 @@ from .exceptions import (
|
|
|
58
57
|
__all__ = [
|
|
59
58
|
# MQTT 客户端
|
|
60
59
|
"MQTTClient",
|
|
61
|
-
"RawAPI", # Raw API (逃生通道)
|
|
62
60
|
# RPC 管理器
|
|
63
61
|
"RPCManager",
|
|
64
|
-
"ConventionalRPCManager", # 约定式 RPC(强约束系统)
|
|
65
62
|
# Event Channel 管理器
|
|
66
63
|
"EventChannelManager",
|
|
67
64
|
"EventMessage",
|
mqttxx/client.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import os
|
|
5
5
|
import ssl
|
|
6
|
-
from typing import Callable, Optional,
|
|
6
|
+
from typing import Callable, Optional, TYPE_CHECKING, Any
|
|
7
7
|
from loguru import logger
|
|
8
8
|
import aiomqtt
|
|
9
9
|
from paho.mqtt.matcher import MQTTMatcher
|
|
@@ -14,155 +14,6 @@ if TYPE_CHECKING:
|
|
|
14
14
|
from .client import MQTTClient
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
class RawAPI:
|
|
18
|
-
"""Raw MQTT API (逃生通道)
|
|
19
|
-
|
|
20
|
-
绕过协议层,直接操作 bytes。
|
|
21
|
-
|
|
22
|
-
警告:
|
|
23
|
-
这是逃生通道,仅在以下场景使用:
|
|
24
|
-
- 自定义协议 (非 JSON/RPC)
|
|
25
|
-
- 性能敏感场景 (避免编解码)
|
|
26
|
-
- 与第三方系统集成
|
|
27
|
-
|
|
28
|
-
示例:
|
|
29
|
-
# 订阅原始 bytes
|
|
30
|
-
client.raw.subscribe("sensor/+", lambda t, b: process(b))
|
|
31
|
-
|
|
32
|
-
# 发布原始 bytes
|
|
33
|
-
await client.raw.publish("topic", b"\\x01\\x02\\x03")
|
|
34
|
-
"""
|
|
35
|
-
|
|
36
|
-
def __init__(self, client: "MQTTClient"):
|
|
37
|
-
"""初始化 Raw API
|
|
38
|
-
|
|
39
|
-
Args:
|
|
40
|
-
client: MQTTClient 实例
|
|
41
|
-
"""
|
|
42
|
-
self._client = client
|
|
43
|
-
|
|
44
|
-
def subscribe(self, pattern: str, handler: Callable[[str, bytes], Any]):
|
|
45
|
-
"""订阅原始消息 (bytes)
|
|
46
|
-
|
|
47
|
-
Args:
|
|
48
|
-
pattern: MQTT topic pattern (支持通配符 +/#)
|
|
49
|
-
handler: 回调函数
|
|
50
|
-
- 签名: async def handler(topic: str, payload: bytes)
|
|
51
|
-
- topic: 实际收到消息的 topic
|
|
52
|
-
- payload: 原始 bytes 数据 (未解码)
|
|
53
|
-
|
|
54
|
-
并发行为:
|
|
55
|
-
- 同一 pattern 的多个 handlers 按注册顺序**顺序调用**(非并发)
|
|
56
|
-
- 如需并发处理,请在 handler 内部使用 asyncio.create_task()
|
|
57
|
-
|
|
58
|
-
示例:
|
|
59
|
-
# 基础用法
|
|
60
|
-
async def on_message(topic: str, payload: bytes):
|
|
61
|
-
data = json.loads(payload.decode('utf-8'))
|
|
62
|
-
print(f"收到消息: {topic} -> {data}")
|
|
63
|
-
|
|
64
|
-
client.raw.subscribe("sensor/+/data", on_message)
|
|
65
|
-
|
|
66
|
-
# 顺序处理(默认)
|
|
67
|
-
async def handler_seq(topic: str, payload: bytes):
|
|
68
|
-
await process_data(payload) # 阻塞后续 handlers
|
|
69
|
-
|
|
70
|
-
# 并发处理(手动)
|
|
71
|
-
async def handler_async(topic: str, payload: bytes):
|
|
72
|
-
asyncio.create_task(process_async(payload)) # 不阻塞
|
|
73
|
-
"""
|
|
74
|
-
# 追踪 handlers (辅助结构)
|
|
75
|
-
if pattern not in self._client._raw_handlers:
|
|
76
|
-
self._client._raw_handlers[pattern] = []
|
|
77
|
-
self._client._raw_handlers[pattern].append(handler)
|
|
78
|
-
|
|
79
|
-
# 同步到 MQTTMatcher (核心匹配引擎)
|
|
80
|
-
self._client._raw_matcher[pattern] = self._client._raw_handlers[pattern]
|
|
81
|
-
|
|
82
|
-
# 订阅 MQTT topic
|
|
83
|
-
self._client._subscriptions.add(pattern)
|
|
84
|
-
if self._client._client:
|
|
85
|
-
asyncio.create_task(self._client._do_subscribe(pattern))
|
|
86
|
-
else:
|
|
87
|
-
logger.info(f"Raw 订阅已队列化 (等待连接) - pattern: {pattern}")
|
|
88
|
-
|
|
89
|
-
logger.debug(f"Raw 订阅成功 - pattern: {pattern}")
|
|
90
|
-
|
|
91
|
-
async def publish(self, topic: str, payload: bytes, qos: int = 0):
|
|
92
|
-
"""发布原始消息 (bytes)
|
|
93
|
-
|
|
94
|
-
Args:
|
|
95
|
-
topic: 目标主题
|
|
96
|
-
payload: 消息载荷 (bytes)
|
|
97
|
-
qos: QoS 等级 (0/1/2)
|
|
98
|
-
|
|
99
|
-
异常:
|
|
100
|
-
aiomqtt.MqttError: 发布失败
|
|
101
|
-
"""
|
|
102
|
-
if not self._client._client:
|
|
103
|
-
logger.warning(f"发布失败:客户端未连接 - topic: {topic}")
|
|
104
|
-
return
|
|
105
|
-
|
|
106
|
-
try:
|
|
107
|
-
await self._client._client.publish(topic, payload, qos=qos)
|
|
108
|
-
except aiomqtt.MqttError as e:
|
|
109
|
-
logger.error(f"发布失败 - topic: {topic}, error: {e}")
|
|
110
|
-
|
|
111
|
-
def unsubscribe(self, pattern: str, handler: Optional[Callable] = None):
|
|
112
|
-
"""取消订阅(修复 P0-B)
|
|
113
|
-
|
|
114
|
-
Args:
|
|
115
|
-
pattern: MQTT topic pattern
|
|
116
|
-
handler: 要移除的 handler(None = 移除所有)
|
|
117
|
-
|
|
118
|
-
示例:
|
|
119
|
-
# 移除特定 handler
|
|
120
|
-
client.raw.unsubscribe("test/+", my_handler)
|
|
121
|
-
|
|
122
|
-
# 移除所有 handler
|
|
123
|
-
client.raw.unsubscribe("test/+")
|
|
124
|
-
"""
|
|
125
|
-
if pattern not in self._client._raw_handlers:
|
|
126
|
-
logger.debug(f"Raw 取消订阅失败:pattern 不存在 - {pattern}")
|
|
127
|
-
return
|
|
128
|
-
|
|
129
|
-
if handler is None:
|
|
130
|
-
# 移除所有 handler
|
|
131
|
-
del self._client._raw_handlers[pattern]
|
|
132
|
-
del self._client._raw_matcher[pattern]
|
|
133
|
-
self._client._subscriptions.discard(pattern)
|
|
134
|
-
|
|
135
|
-
# 发送 MQTT UNSUBSCRIBE
|
|
136
|
-
if self._client._client:
|
|
137
|
-
asyncio.create_task(self._client._client.unsubscribe(pattern))
|
|
138
|
-
|
|
139
|
-
logger.debug(f"Raw 取消订阅(全部)- pattern: {pattern}")
|
|
140
|
-
else:
|
|
141
|
-
# 移除特定 handler
|
|
142
|
-
handlers = self._client._raw_handlers[pattern]
|
|
143
|
-
if handler in handlers:
|
|
144
|
-
handlers.remove(handler)
|
|
145
|
-
|
|
146
|
-
# 如果没有 handler 了,完全取消订阅
|
|
147
|
-
if not handlers:
|
|
148
|
-
del self._client._raw_handlers[pattern]
|
|
149
|
-
del self._client._raw_matcher[pattern]
|
|
150
|
-
self._client._subscriptions.discard(pattern)
|
|
151
|
-
|
|
152
|
-
if self._client._client:
|
|
153
|
-
asyncio.create_task(self._client._client.unsubscribe(pattern))
|
|
154
|
-
|
|
155
|
-
logger.debug(
|
|
156
|
-
f"Raw 取消订阅(全部,最后一个 handler)- pattern: {pattern}"
|
|
157
|
-
)
|
|
158
|
-
else:
|
|
159
|
-
logger.debug(
|
|
160
|
-
f"Raw 取消订阅(部分)- pattern: {pattern}, 剩余 {len(handlers)} 个 handler"
|
|
161
|
-
)
|
|
162
|
-
else:
|
|
163
|
-
logger.debug(f"Raw 取消订阅失败:handler 不存在 - pattern: {pattern}")
|
|
164
|
-
|
|
165
|
-
|
|
166
17
|
class MQTTClient:
|
|
167
18
|
"""基于 aiomqtt 的 MQTT 客户端
|
|
168
19
|
|
|
@@ -171,11 +22,6 @@ class MQTTClient:
|
|
|
171
22
|
- 不自动重连,需要手动实现重连循环(官方推荐模式)
|
|
172
23
|
- 使用 `async for message in client.messages` 异步迭代器
|
|
173
24
|
|
|
174
|
-
设计约束:
|
|
175
|
-
- **单 Event Loop 设计**: 所有方法(subscribe/unsubscribe/handler)
|
|
176
|
-
必须在同一个 asyncio event loop 中调用
|
|
177
|
-
- **不支持多线程/多 loop 并发**: 如果需要在多个线程中使用,
|
|
178
|
-
请为每个线程创建独立的 MQTTClient 实例
|
|
179
25
|
|
|
180
26
|
并发安全:
|
|
181
27
|
- ✅ 单 loop 内并发调用 subscribe/unsubscribe:安全
|
|
@@ -185,20 +31,9 @@ class MQTTClient:
|
|
|
185
31
|
async def main():
|
|
186
32
|
client = MQTTClient(config)
|
|
187
33
|
await client.connect()
|
|
188
|
-
client.
|
|
189
|
-
client.
|
|
190
|
-
|
|
191
|
-
# ❌ 错误:多线程
|
|
192
|
-
def thread1():
|
|
193
|
-
client.raw.subscribe("topic", handler1) # 不安全
|
|
194
|
-
|
|
195
|
-
def thread2():
|
|
196
|
-
client.raw.subscribe("topic", handler2) # 不安全
|
|
34
|
+
client.subscribe("topic", handler1) # 安全
|
|
35
|
+
client.subscribe("topic", handler2) # 安全
|
|
197
36
|
|
|
198
|
-
# ✅ 正确:多线程各自创建实例
|
|
199
|
-
def thread1():
|
|
200
|
-
client1 = MQTTClient(config)
|
|
201
|
-
asyncio.run(client1.connect())
|
|
202
37
|
"""
|
|
203
38
|
|
|
204
39
|
def __init__(self, config: MQTTConfig):
|
|
@@ -221,11 +56,12 @@ class MQTTClient:
|
|
|
221
56
|
self._raw_handlers: dict[str, list[Callable]] = {}
|
|
222
57
|
|
|
223
58
|
self._running = False
|
|
224
|
-
self._connected = False #
|
|
59
|
+
self._connected = False # 真实连接状态标志
|
|
225
60
|
self._reconnect_task: Optional[asyncio.Task] = None
|
|
226
61
|
self._message_task: Optional[asyncio.Task] = None
|
|
62
|
+
self._tls_context: Optional[ssl.SSLContext] = None # TLS 上下文(复用)
|
|
227
63
|
|
|
228
|
-
# 消息处理队列和 Workers
|
|
64
|
+
# 消息处理队列和 Workers(
|
|
229
65
|
self._message_queue: asyncio.Queue = asyncio.Queue(
|
|
230
66
|
maxsize=config.message_queue_maxsize
|
|
231
67
|
)
|
|
@@ -233,9 +69,6 @@ class MQTTClient:
|
|
|
233
69
|
# Worker 数量:默认 CPU核数×2(IO-bound 最优)
|
|
234
70
|
self._num_workers = config.num_workers or (os.cpu_count() or 1) * 2
|
|
235
71
|
|
|
236
|
-
# Raw API (逃生通道)
|
|
237
|
-
self._raw_api = RawAPI(self)
|
|
238
|
-
|
|
239
72
|
async def connect(self):
|
|
240
73
|
"""连接到 MQTT Broker
|
|
241
74
|
|
|
@@ -248,22 +81,49 @@ class MQTTClient:
|
|
|
248
81
|
|
|
249
82
|
self._running = True
|
|
250
83
|
|
|
251
|
-
#
|
|
84
|
+
# 创建 TLS 上下文(只创建一次,可复用)
|
|
85
|
+
if self.config.tls.enabled:
|
|
86
|
+
self._tls_context = self._create_tls_context()
|
|
87
|
+
|
|
88
|
+
# 创建 aiomqtt.Client(只创建一次,在重连循环中复用)
|
|
89
|
+
self._client = aiomqtt.Client(
|
|
90
|
+
hostname=self.config.broker_host,
|
|
91
|
+
port=self.config.broker_port,
|
|
92
|
+
username=self.config.auth.username,
|
|
93
|
+
password=self.config.auth.password,
|
|
94
|
+
identifier=self.config.client_id or None,
|
|
95
|
+
clean_session=self.config.clean_session,
|
|
96
|
+
keepalive=self.config.keepalive,
|
|
97
|
+
tls_context=self._tls_context,
|
|
98
|
+
max_queued_outgoing_messages=self.config.max_queued_messages or None,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# 启动消息处理 workers
|
|
252
102
|
for i in range(self._num_workers):
|
|
253
103
|
worker = asyncio.create_task(self._worker(i), name=f"mqtt_worker_{i}")
|
|
254
104
|
self._workers.append(worker)
|
|
255
|
-
logger.info(f"MQTT 已启动 {self._num_workers} 个消息处理 worker")
|
|
256
105
|
|
|
257
106
|
self._reconnect_task = asyncio.create_task(
|
|
258
107
|
self._reconnect_loop(), name="mqtt_reconnect"
|
|
259
108
|
)
|
|
260
109
|
|
|
110
|
+
# 等待首次连接建立(最多 60 秒)
|
|
111
|
+
for _ in range(600): # 60 秒超时,每次检查 0.1 秒
|
|
112
|
+
if self._connected:
|
|
113
|
+
break
|
|
114
|
+
await asyncio.sleep(0.1)
|
|
115
|
+
else:
|
|
116
|
+
self._running = False # 清理状态
|
|
117
|
+
raise TimeoutError(
|
|
118
|
+
f"MQTT 连接超时(60秒):{self.config.broker_host}:{self.config.broker_port}"
|
|
119
|
+
)
|
|
120
|
+
|
|
261
121
|
async def disconnect(self):
|
|
262
122
|
"""断开连接并清理资源"""
|
|
263
123
|
self._running = False
|
|
264
|
-
self._connected = False #
|
|
124
|
+
self._connected = False # :标记为未连接
|
|
265
125
|
|
|
266
|
-
# 等待 workers
|
|
126
|
+
# 等待 workers 处理完队列
|
|
267
127
|
if self._workers:
|
|
268
128
|
# 等待所有 workers 完成(最多 5 秒)
|
|
269
129
|
_, pending = await asyncio.wait(self._workers, timeout=5.0)
|
|
@@ -289,9 +149,9 @@ class MQTTClient:
|
|
|
289
149
|
except asyncio.CancelledError:
|
|
290
150
|
pass
|
|
291
151
|
|
|
292
|
-
#
|
|
293
|
-
# 不需要显式调用 disconnect()(该方法不存在)
|
|
152
|
+
# 清理 Client 和 TLS 上下文
|
|
294
153
|
self._client = None
|
|
154
|
+
self._tls_context = None
|
|
295
155
|
|
|
296
156
|
logger.info("MQTT 客户端已断开")
|
|
297
157
|
|
|
@@ -325,40 +185,21 @@ class MQTTClient:
|
|
|
325
185
|
异常处理:
|
|
326
186
|
- aiomqtt.MqttError:连接/协议错误,触发重连
|
|
327
187
|
- asyncio.CancelledError:任务被取消,退出循环
|
|
188
|
+
|
|
189
|
+
关键改进:
|
|
190
|
+
- Client 在 connect() 中创建(只创建一次)
|
|
191
|
+
- 循环内只使用 async with self._client: 来连接
|
|
192
|
+
- 符合 aiomqtt 官方推荐模式
|
|
328
193
|
"""
|
|
329
194
|
attempt = 0
|
|
330
195
|
interval = self.config.reconnect.interval
|
|
331
196
|
|
|
332
197
|
while self._running:
|
|
333
198
|
try:
|
|
334
|
-
#
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
)
|
|
338
|
-
|
|
339
|
-
# region 调用代码溯源(aiomqtt.Client)
|
|
340
|
-
# aiomqtt.Client 是异步上下文管理器
|
|
341
|
-
# 文档:https://aiomqtt.bo3hm.com/reconnection.html
|
|
342
|
-
# 进入上下文时自动连接
|
|
343
|
-
# endregion
|
|
344
|
-
async with aiomqtt.Client(
|
|
345
|
-
hostname=self.config.broker_host,
|
|
346
|
-
port=self.config.broker_port,
|
|
347
|
-
username=self.config.auth.username,
|
|
348
|
-
password=self.config.auth.password,
|
|
349
|
-
identifier=self.config.client_id
|
|
350
|
-
or None, # 空字符串 → None = 自动生成
|
|
351
|
-
clean_session=self.config.clean_session,
|
|
352
|
-
keepalive=self.config.keepalive,
|
|
353
|
-
tls_context=tls_context,
|
|
354
|
-
max_queued_outgoing_messages=self.config.max_queued_messages
|
|
355
|
-
or None,
|
|
356
|
-
) as client:
|
|
357
|
-
self._client = client
|
|
199
|
+
# 使用已创建的 Client(在 connect() 中创建)
|
|
200
|
+
# async with 会连接,退出时会自动断开
|
|
201
|
+
async with self._client:
|
|
358
202
|
self._connected = True # 修复 P0-2:标记为已连接
|
|
359
|
-
logger.success(
|
|
360
|
-
f"MQTT 连接成功 - {self.config.broker_host}:{self.config.broker_port}"
|
|
361
|
-
)
|
|
362
203
|
|
|
363
204
|
# 重置重连计数
|
|
364
205
|
attempt = 0
|
|
@@ -377,7 +218,6 @@ class MQTTClient:
|
|
|
377
218
|
|
|
378
219
|
except aiomqtt.MqttError as e:
|
|
379
220
|
logger.error(f"MQTT 连接失败: {e}")
|
|
380
|
-
self._client = None
|
|
381
221
|
self._connected = False # 修复 P0-2:标记为未连接
|
|
382
222
|
|
|
383
223
|
# 检查重连次数限制
|
|
@@ -405,7 +245,7 @@ class MQTTClient:
|
|
|
405
245
|
await asyncio.sleep(interval)
|
|
406
246
|
|
|
407
247
|
async def _message_loop(self):
|
|
408
|
-
"""
|
|
248
|
+
"""消息接收循环
|
|
409
249
|
|
|
410
250
|
使用 async for 迭代消息,将消息放入队列由 workers 处理
|
|
411
251
|
|
|
@@ -458,24 +298,107 @@ class MQTTClient:
|
|
|
458
298
|
# 队列为空,继续等待
|
|
459
299
|
continue
|
|
460
300
|
except asyncio.CancelledError:
|
|
461
|
-
|
|
301
|
+
# Worker {worker_id} 被取消
|
|
462
302
|
break
|
|
463
303
|
except Exception as e:
|
|
464
304
|
logger.exception(f"Worker {worker_id} 异常: {e}")
|
|
465
305
|
# 继续运行,不退出
|
|
466
306
|
|
|
307
|
+
def subscribe(self, pattern: str, handler: Callable[[str, bytes], Any]):
|
|
308
|
+
"""订阅原始消息 (bytes)
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
pattern: MQTT topic pattern (支持通配符 +/#)
|
|
312
|
+
handler: 回调函数
|
|
313
|
+
- 签名: async def handler(topic: str, payload: bytes)
|
|
314
|
+
- topic: 实际收到消息的 topic
|
|
315
|
+
- payload: 原始 bytes 数据 (未解码)
|
|
316
|
+
|
|
317
|
+
并发行为:
|
|
318
|
+
- 同一 pattern 的多个 handlers 按注册顺序**顺序调用**(非并发)
|
|
319
|
+
- 如需并发处理,请在 handler 内部使用 asyncio.create_task()
|
|
320
|
+
|
|
321
|
+
注意:
|
|
322
|
+
- 订阅在重连时自动恢复
|
|
323
|
+
"""
|
|
324
|
+
if pattern not in self._raw_handlers:
|
|
325
|
+
self._raw_handlers[pattern] = []
|
|
326
|
+
self._raw_matcher[pattern] = self._raw_handlers[pattern]
|
|
327
|
+
|
|
328
|
+
# 立即向 MQTT broker 订阅
|
|
329
|
+
if self._client:
|
|
330
|
+
# 使用 asyncio.create_task 避免阻塞
|
|
331
|
+
asyncio.create_task(self._client.subscribe(pattern))
|
|
332
|
+
|
|
333
|
+
self._raw_handlers[pattern].append(handler)
|
|
334
|
+
self._subscriptions.add(pattern)
|
|
335
|
+
|
|
336
|
+
logger.debug(f"订阅已注册 - pattern: {pattern}")
|
|
337
|
+
|
|
467
338
|
@property
|
|
468
|
-
def raw(self) ->
|
|
469
|
-
"""
|
|
339
|
+
def raw(self) -> aiomqtt.Client:
|
|
340
|
+
"""暴露底层 aiomqtt.Client,用于高级用法
|
|
470
341
|
|
|
471
|
-
|
|
472
|
-
|
|
342
|
+
使用场景:
|
|
343
|
+
await client.raw.publish(topic, payload, qos=1, retain=False)
|
|
473
344
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
345
|
+
Raises:
|
|
346
|
+
RuntimeError: 客户端未连接
|
|
347
|
+
"""
|
|
348
|
+
if not self._client:
|
|
349
|
+
raise RuntimeError("Client not connected")
|
|
350
|
+
return self._client
|
|
351
|
+
|
|
352
|
+
def unsubscribe(self, pattern: str, handler: Optional[Callable] = None):
|
|
353
|
+
"""取消订阅
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
pattern: MQTT topic pattern
|
|
357
|
+
handler: 要移除的 handler(None = 移除所有)
|
|
358
|
+
|
|
359
|
+
注意:
|
|
360
|
+
- 当某个 pattern 的最后一个 handler 被移除时:
|
|
361
|
+
- 若当前已连接,会向 broker 发送 MQTT UNSUBSCRIBE
|
|
362
|
+
- 无论是否连接,都会清理本地 matcher/handlers
|
|
477
363
|
"""
|
|
478
|
-
|
|
364
|
+
if pattern not in self._raw_handlers:
|
|
365
|
+
logger.debug(f"取消订阅失败:pattern 不存在 - {pattern}")
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
should_broker_unsubscribe = False
|
|
369
|
+
|
|
370
|
+
if handler is None:
|
|
371
|
+
del self._raw_handlers[pattern]
|
|
372
|
+
del self._raw_matcher[pattern]
|
|
373
|
+
self._subscriptions.discard(pattern)
|
|
374
|
+
logger.debug(f"取消订阅(全部)- pattern: {pattern}")
|
|
375
|
+
should_broker_unsubscribe = True
|
|
376
|
+
else:
|
|
377
|
+
handlers = self._raw_handlers[pattern]
|
|
378
|
+
if handler in handlers:
|
|
379
|
+
handlers.remove(handler)
|
|
380
|
+
|
|
381
|
+
if not handlers:
|
|
382
|
+
del self._raw_handlers[pattern]
|
|
383
|
+
del self._raw_matcher[pattern]
|
|
384
|
+
self._subscriptions.discard(pattern)
|
|
385
|
+
logger.debug(
|
|
386
|
+
f"取消订阅(全部,最后一个 handler)- pattern: {pattern}"
|
|
387
|
+
)
|
|
388
|
+
should_broker_unsubscribe = True
|
|
389
|
+
else:
|
|
390
|
+
logger.debug(
|
|
391
|
+
f"取消订阅(部分)- pattern: {pattern}, 剩余 {len(handlers)} 个 handler"
|
|
392
|
+
)
|
|
393
|
+
else:
|
|
394
|
+
logger.debug(f"取消订阅失败:handler 不存在 - pattern: {pattern}")
|
|
395
|
+
|
|
396
|
+
if should_broker_unsubscribe and self._client and self.is_connected:
|
|
397
|
+
asyncio.create_task(
|
|
398
|
+
self._client.unsubscribe(pattern),
|
|
399
|
+
name=f"mqtt_unsub_{pattern}",
|
|
400
|
+
)
|
|
401
|
+
logger.debug(f"已向 broker 发送 UNSUBSCRIBE - pattern: {pattern}")
|
|
479
402
|
|
|
480
403
|
async def _handle_message(self, message: aiomqtt.Message):
|
|
481
404
|
"""处理单条消息(传输层,只处理 bytes)
|
|
@@ -510,21 +433,6 @@ class MQTTClient:
|
|
|
510
433
|
except Exception as e:
|
|
511
434
|
logger.exception(f"Handler 异常 - topic: {topic_str}, error: {e}")
|
|
512
435
|
|
|
513
|
-
async def _do_subscribe(self, topic: str):
|
|
514
|
-
"""执行订阅(内部方法)
|
|
515
|
-
|
|
516
|
-
Args:
|
|
517
|
-
topic: MQTT 主题
|
|
518
|
-
"""
|
|
519
|
-
if not self._client:
|
|
520
|
-
return
|
|
521
|
-
|
|
522
|
-
try:
|
|
523
|
-
await self._client.subscribe(topic)
|
|
524
|
-
logger.success(f"订阅成功 - topic: {topic}")
|
|
525
|
-
except aiomqtt.MqttError as e:
|
|
526
|
-
logger.error(f"订阅失败 - topic: {topic}, error: {e}")
|
|
527
|
-
|
|
528
436
|
async def _restore_subscriptions(self):
|
|
529
437
|
"""恢复所有订阅(重连后调用)
|
|
530
438
|
|
|
@@ -542,7 +450,10 @@ class MQTTClient:
|
|
|
542
450
|
logger.info(f"恢复 {len(topics)} 个订阅...")
|
|
543
451
|
|
|
544
452
|
for topic in topics:
|
|
545
|
-
|
|
453
|
+
try:
|
|
454
|
+
await self._client.subscribe(topic)
|
|
455
|
+
except aiomqtt.MqttError as e:
|
|
456
|
+
logger.error(f"恢复订阅失败 - topic: {topic}, error: {e}")
|
|
546
457
|
|
|
547
458
|
logger.success("订阅恢复完成")
|
|
548
459
|
|
mqttxx/events.py
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import time
|
|
5
|
-
from typing import Callable, Any, Optional,
|
|
5
|
+
from typing import Callable, Any, Optional, overload
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from loguru import logger
|
|
8
8
|
|
|
9
9
|
from .client import MQTTClient
|
|
10
|
-
|
|
10
|
+
import json
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
@dataclass
|
|
@@ -93,29 +93,29 @@ class EventMessage:
|
|
|
93
93
|
source=data.get("source", ""),
|
|
94
94
|
)
|
|
95
95
|
|
|
96
|
-
def encode(self
|
|
97
|
-
"""编码为 bytes
|
|
98
|
-
|
|
99
|
-
Args:
|
|
100
|
-
codec: 编解码器(默认 JSONCodec)
|
|
96
|
+
def encode(self) -> bytes:
|
|
97
|
+
"""编码为 bytes(JSON 格式)
|
|
101
98
|
|
|
102
99
|
Returns:
|
|
103
|
-
|
|
100
|
+
UTF-8 编码的 JSON bytes
|
|
104
101
|
"""
|
|
105
|
-
return
|
|
102
|
+
return json.dumps(self.to_dict()).encode('utf-8')
|
|
106
103
|
|
|
107
104
|
@classmethod
|
|
108
|
-
def decode(cls, data: bytes
|
|
109
|
-
"""从 bytes
|
|
105
|
+
def decode(cls, data: bytes) -> "EventMessage":
|
|
106
|
+
"""从 bytes 解码(JSON 格式)
|
|
110
107
|
|
|
111
108
|
Args:
|
|
112
|
-
data:
|
|
113
|
-
codec: 编解码器(默认 JSONCodec)
|
|
109
|
+
data: UTF-8 编码的 JSON bytes
|
|
114
110
|
|
|
115
111
|
Returns:
|
|
116
112
|
EventMessage 对象
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
UnicodeDecodeError: UTF-8 解码失败
|
|
116
|
+
json.JSONDecodeError: JSON 解析失败
|
|
117
117
|
"""
|
|
118
|
-
obj =
|
|
118
|
+
obj = json.loads(data.decode('utf-8'))
|
|
119
119
|
return cls.from_dict(obj)
|
|
120
120
|
|
|
121
121
|
|
|
@@ -175,6 +175,24 @@ class EventChannelManager:
|
|
|
175
175
|
|
|
176
176
|
logger.info("EventChannelManager 已初始化")
|
|
177
177
|
|
|
178
|
+
@overload
|
|
179
|
+
def subscribe(
|
|
180
|
+
self,
|
|
181
|
+
pattern: str,
|
|
182
|
+
handler: None = None
|
|
183
|
+
) -> Callable[[EventHandler], EventHandler]:
|
|
184
|
+
"""装饰器模式:返回装饰器函数"""
|
|
185
|
+
...
|
|
186
|
+
|
|
187
|
+
@overload
|
|
188
|
+
def subscribe(
|
|
189
|
+
self,
|
|
190
|
+
pattern: str,
|
|
191
|
+
handler: EventHandler
|
|
192
|
+
) -> EventHandler:
|
|
193
|
+
"""直接注册模式:返回处理器本身"""
|
|
194
|
+
...
|
|
195
|
+
|
|
178
196
|
def subscribe(self, pattern: str, handler: Optional[EventHandler] = None):
|
|
179
197
|
"""订阅事件主题(支持通配符)
|
|
180
198
|
|
|
@@ -211,8 +229,8 @@ class EventChannelManager:
|
|
|
211
229
|
async def dispatcher(topic: str, payload: bytes):
|
|
212
230
|
"""专属 dispatcher(bytes → dict → handler)"""
|
|
213
231
|
try:
|
|
214
|
-
#
|
|
215
|
-
data =
|
|
232
|
+
# 解码(JSON 格式)
|
|
233
|
+
data = json.loads(payload.decode('utf-8'))
|
|
216
234
|
|
|
217
235
|
# 分发到所有 handlers(使用 get 避免 KeyError)
|
|
218
236
|
for h in self._patterns.get(pattern, []):
|
|
@@ -231,8 +249,8 @@ class EventChannelManager:
|
|
|
231
249
|
# 保存 dispatcher 引用(修复 P0-B)
|
|
232
250
|
self._dispatchers[pattern] = dispatcher
|
|
233
251
|
|
|
234
|
-
# 注册到 MQTTClient
|
|
235
|
-
self._client.
|
|
252
|
+
# 注册到 MQTTClient 层
|
|
253
|
+
self._client.subscribe(pattern, dispatcher)
|
|
236
254
|
|
|
237
255
|
self._patterns[pattern].append(func)
|
|
238
256
|
logger.debug(
|
|
@@ -253,7 +271,7 @@ class EventChannelManager:
|
|
|
253
271
|
handler: 要移除的处理器(None = 移除所有)
|
|
254
272
|
|
|
255
273
|
改进:
|
|
256
|
-
现在会真正调用
|
|
274
|
+
现在会真正调用 MQTTClient.unsubscribe 来清理底层 MQTT 订阅
|
|
257
275
|
避免内存泄漏
|
|
258
276
|
"""
|
|
259
277
|
if pattern not in self._patterns:
|
|
@@ -266,7 +284,7 @@ class EventChannelManager:
|
|
|
266
284
|
# 清理 dispatcher(修复 P0-B)
|
|
267
285
|
dispatcher = self._dispatchers.pop(pattern, None)
|
|
268
286
|
if dispatcher:
|
|
269
|
-
self._client.
|
|
287
|
+
self._client.unsubscribe(pattern, dispatcher)
|
|
270
288
|
|
|
271
289
|
logger.info(f"已取消订阅 - pattern: {pattern}")
|
|
272
290
|
else:
|
|
@@ -280,11 +298,13 @@ class EventChannelManager:
|
|
|
280
298
|
|
|
281
299
|
dispatcher = self._dispatchers.pop(pattern, None)
|
|
282
300
|
if dispatcher:
|
|
283
|
-
self._client.
|
|
301
|
+
self._client.unsubscribe(pattern, dispatcher)
|
|
284
302
|
|
|
285
303
|
logger.info(f"已取消订阅(最后一个 handler)- pattern: {pattern}")
|
|
286
304
|
else:
|
|
287
|
-
logger.debug(
|
|
305
|
+
logger.debug(
|
|
306
|
+
f"已移除处理器 - pattern: {pattern}, 剩余 {len(self._patterns[pattern])} 个"
|
|
307
|
+
)
|
|
288
308
|
|
|
289
309
|
async def publish(
|
|
290
310
|
self,
|
|
@@ -328,13 +348,12 @@ class EventChannelManager:
|
|
|
328
348
|
"""
|
|
329
349
|
# 编码消息
|
|
330
350
|
if isinstance(message, EventMessage):
|
|
331
|
-
payload = message.encode(
|
|
351
|
+
payload = message.encode()
|
|
332
352
|
elif isinstance(message, dict):
|
|
333
|
-
payload =
|
|
353
|
+
payload = json.dumps(message).encode('utf-8')
|
|
334
354
|
else:
|
|
335
355
|
# 其他类型自动包装
|
|
336
|
-
payload =
|
|
356
|
+
payload = json.dumps({"data": message}).encode('utf-8')
|
|
337
357
|
|
|
338
358
|
# 直接发布(零开销)
|
|
339
359
|
await self._client.raw.publish(topic, payload, qos=qos)
|
|
340
|
-
logger.debug(f"事件已发布 - topic: {topic}, qos: {qos}")
|