mqttxx 2.0.3__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 +11 -6
- mqttxx/client.py +239 -183
- mqttxx/config.py +14 -0
- mqttxx/events.py +359 -0
- mqttxx/protocol.py +93 -22
- mqttxx/rpc.py +81 -22
- mqttxx-3.2.1.dist-info/METADATA +931 -0
- mqttxx-3.2.1.dist-info/RECORD +12 -0
- mqttxx/conventions.py +0 -145
- mqttxx-2.0.3.dist-info/METADATA +0 -490
- mqttxx-2.0.3.dist-info/RECORD +0 -12
- {mqttxx-2.0.3.dist-info → mqttxx-3.2.1.dist-info}/LICENSE +0 -0
- {mqttxx-2.0.3.dist-info → mqttxx-3.2.1.dist-info}/WHEEL +0 -0
- {mqttxx-2.0.3.dist-info → mqttxx-3.2.1.dist-info}/top_level.txt +0 -0
mqttxx/__init__.py
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
提供:
|
|
4
4
|
- MQTTClient: MQTT 连接管理(自动重连、订阅队列化、TLS/SSL 支持)
|
|
5
5
|
- RPCManager: 双向对等 RPC 调用(超时控制、权限检查、并发限制)
|
|
6
|
+
- EventChannelManager: 高吞吐事件广播通道(单向、无返回值)
|
|
6
7
|
- 配置对象: MQTTConfig, TLSConfig, AuthConfig, RPCConfig 等
|
|
7
|
-
- 协议定义: RPCRequest, RPCResponse
|
|
8
|
+
- 协议定义: RPCRequest, RPCResponse, EventMessage
|
|
8
9
|
- 异常系统: 统一错误码和异常层次
|
|
9
10
|
"""
|
|
10
11
|
|
|
11
|
-
__version__ = "2.0
|
|
12
|
+
__version__ = "3.2.0"
|
|
12
13
|
__author__ = "MQTTX Team"
|
|
13
14
|
|
|
14
15
|
# 核心客户端
|
|
@@ -16,7 +17,9 @@ from .client import MQTTClient
|
|
|
16
17
|
|
|
17
18
|
# RPC 管理器
|
|
18
19
|
from .rpc import RPCManager
|
|
19
|
-
|
|
20
|
+
|
|
21
|
+
# Event Channel 管理器
|
|
22
|
+
from .events import EventChannelManager, EventMessage
|
|
20
23
|
|
|
21
24
|
# 配置对象
|
|
22
25
|
from .config import (
|
|
@@ -31,7 +34,7 @@ from .config import (
|
|
|
31
34
|
from .protocol import (
|
|
32
35
|
RPCRequest,
|
|
33
36
|
RPCResponse,
|
|
34
|
-
parse_message
|
|
37
|
+
# parse_message 是内部 API,不再导出
|
|
35
38
|
)
|
|
36
39
|
|
|
37
40
|
# 异常系统
|
|
@@ -56,7 +59,9 @@ __all__ = [
|
|
|
56
59
|
"MQTTClient",
|
|
57
60
|
# RPC 管理器
|
|
58
61
|
"RPCManager",
|
|
59
|
-
|
|
62
|
+
# Event Channel 管理器
|
|
63
|
+
"EventChannelManager",
|
|
64
|
+
"EventMessage",
|
|
60
65
|
# 配置对象
|
|
61
66
|
"MQTTConfig",
|
|
62
67
|
"TLSConfig",
|
|
@@ -66,7 +71,7 @@ __all__ = [
|
|
|
66
71
|
# 协议定义
|
|
67
72
|
"RPCRequest",
|
|
68
73
|
"RPCResponse",
|
|
69
|
-
|
|
74
|
+
# parse_message 已移除 - 内部 API
|
|
70
75
|
# 异常系统
|
|
71
76
|
"ErrorCode",
|
|
72
77
|
"MQTTXError",
|
mqttxx/client.py
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
# aiomqtt 高级封装 - 基于纯 async/await 架构
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
import
|
|
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
|
+
from paho.mqtt.matcher import MQTTMatcher
|
|
9
10
|
|
|
10
11
|
from .config import MQTTConfig
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .client import MQTTClient
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
class MQTTClient:
|
|
@@ -19,6 +21,19 @@ class MQTTClient:
|
|
|
19
21
|
- aiomqtt 基于 paho-mqtt 封装,成熟稳定
|
|
20
22
|
- 不自动重连,需要手动实现重连循环(官方推荐模式)
|
|
21
23
|
- 使用 `async for message in client.messages` 异步迭代器
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
并发安全:
|
|
27
|
+
- ✅ 单 loop 内并发调用 subscribe/unsubscribe:安全
|
|
28
|
+
|
|
29
|
+
示例:
|
|
30
|
+
# ✅ 正确:单 loop
|
|
31
|
+
async def main():
|
|
32
|
+
client = MQTTClient(config)
|
|
33
|
+
await client.connect()
|
|
34
|
+
client.subscribe("topic", handler1) # 安全
|
|
35
|
+
client.subscribe("topic", handler2) # 安全
|
|
36
|
+
|
|
22
37
|
"""
|
|
23
38
|
|
|
24
39
|
def __init__(self, config: MQTTConfig):
|
|
@@ -33,11 +48,26 @@ class MQTTClient:
|
|
|
33
48
|
self.config = config
|
|
34
49
|
self._client: Optional[aiomqtt.Client] = None
|
|
35
50
|
self._subscriptions: set[str] = set() # 订阅列表(用于重连恢复)
|
|
36
|
-
|
|
51
|
+
|
|
52
|
+
# Raw 订阅(使用 MQTTMatcher 进行通配符匹配)
|
|
53
|
+
# _raw_matcher 是核心匹配引擎(用于 iter_match)
|
|
54
|
+
# _raw_handlers 是辅助追踪结构(因 MQTTMatcher 不支持 getitem/contains)
|
|
55
|
+
self._raw_matcher = MQTTMatcher()
|
|
56
|
+
self._raw_handlers: dict[str, list[Callable]] = {}
|
|
57
|
+
|
|
37
58
|
self._running = False
|
|
38
|
-
self._connected = False #
|
|
59
|
+
self._connected = False # 真实连接状态标志
|
|
39
60
|
self._reconnect_task: Optional[asyncio.Task] = None
|
|
40
61
|
self._message_task: Optional[asyncio.Task] = None
|
|
62
|
+
self._tls_context: Optional[ssl.SSLContext] = None # TLS 上下文(复用)
|
|
63
|
+
|
|
64
|
+
# 消息处理队列和 Workers(
|
|
65
|
+
self._message_queue: asyncio.Queue = asyncio.Queue(
|
|
66
|
+
maxsize=config.message_queue_maxsize
|
|
67
|
+
)
|
|
68
|
+
self._workers: list[asyncio.Task] = []
|
|
69
|
+
# Worker 数量:默认 CPU核数×2(IO-bound 最优)
|
|
70
|
+
self._num_workers = config.num_workers or (os.cpu_count() or 1) * 2
|
|
41
71
|
|
|
42
72
|
async def connect(self):
|
|
43
73
|
"""连接到 MQTT Broker
|
|
@@ -51,14 +81,56 @@ class MQTTClient:
|
|
|
51
81
|
|
|
52
82
|
self._running = True
|
|
53
83
|
|
|
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
|
|
102
|
+
for i in range(self._num_workers):
|
|
103
|
+
worker = asyncio.create_task(self._worker(i), name=f"mqtt_worker_{i}")
|
|
104
|
+
self._workers.append(worker)
|
|
105
|
+
|
|
54
106
|
self._reconnect_task = asyncio.create_task(
|
|
55
107
|
self._reconnect_loop(), name="mqtt_reconnect"
|
|
56
108
|
)
|
|
57
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
|
+
|
|
58
121
|
async def disconnect(self):
|
|
59
122
|
"""断开连接并清理资源"""
|
|
60
123
|
self._running = False
|
|
61
|
-
self._connected = False #
|
|
124
|
+
self._connected = False # :标记为未连接
|
|
125
|
+
|
|
126
|
+
# 等待 workers 处理完队列
|
|
127
|
+
if self._workers:
|
|
128
|
+
# 等待所有 workers 完成(最多 5 秒)
|
|
129
|
+
_, pending = await asyncio.wait(self._workers, timeout=5.0)
|
|
130
|
+
# 取消未完成的 workers
|
|
131
|
+
for w in pending:
|
|
132
|
+
w.cancel()
|
|
133
|
+
self._workers.clear()
|
|
62
134
|
|
|
63
135
|
# 取消后台任务
|
|
64
136
|
if self._reconnect_task:
|
|
@@ -68,15 +140,18 @@ class MQTTClient:
|
|
|
68
140
|
except asyncio.CancelledError:
|
|
69
141
|
pass
|
|
70
142
|
|
|
71
|
-
#
|
|
143
|
+
# 取消消息处理任务(修复 P0-D)
|
|
144
|
+
# 这会导致 _reconnect_loop 中的 async with 块退出,aiomqtt 自动清理连接
|
|
72
145
|
if self._message_task:
|
|
73
146
|
self._message_task.cancel()
|
|
74
147
|
try:
|
|
75
148
|
await self._message_task
|
|
76
149
|
except asyncio.CancelledError:
|
|
77
150
|
pass
|
|
78
|
-
|
|
79
|
-
|
|
151
|
+
|
|
152
|
+
# 清理 Client 和 TLS 上下文
|
|
153
|
+
self._client = None
|
|
154
|
+
self._tls_context = None
|
|
80
155
|
|
|
81
156
|
logger.info("MQTT 客户端已断开")
|
|
82
157
|
|
|
@@ -110,40 +185,21 @@ class MQTTClient:
|
|
|
110
185
|
异常处理:
|
|
111
186
|
- aiomqtt.MqttError:连接/协议错误,触发重连
|
|
112
187
|
- asyncio.CancelledError:任务被取消,退出循环
|
|
188
|
+
|
|
189
|
+
关键改进:
|
|
190
|
+
- Client 在 connect() 中创建(只创建一次)
|
|
191
|
+
- 循环内只使用 async with self._client: 来连接
|
|
192
|
+
- 符合 aiomqtt 官方推荐模式
|
|
113
193
|
"""
|
|
114
194
|
attempt = 0
|
|
115
195
|
interval = self.config.reconnect.interval
|
|
116
196
|
|
|
117
197
|
while self._running:
|
|
118
198
|
try:
|
|
119
|
-
#
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
)
|
|
123
|
-
|
|
124
|
-
# region 调用代码溯源(aiomqtt.Client)
|
|
125
|
-
# aiomqtt.Client 是异步上下文管理器
|
|
126
|
-
# 文档:https://aiomqtt.bo3hm.com/reconnection.html
|
|
127
|
-
# 进入上下文时自动连接
|
|
128
|
-
# endregion
|
|
129
|
-
async with aiomqtt.Client(
|
|
130
|
-
hostname=self.config.broker_host,
|
|
131
|
-
port=self.config.broker_port,
|
|
132
|
-
username=self.config.auth.username,
|
|
133
|
-
password=self.config.auth.password,
|
|
134
|
-
identifier=self.config.client_id
|
|
135
|
-
or None, # 空字符串 → None = 自动生成
|
|
136
|
-
clean_session=self.config.clean_session,
|
|
137
|
-
keepalive=self.config.keepalive,
|
|
138
|
-
tls_context=tls_context,
|
|
139
|
-
max_queued_outgoing_messages=self.config.max_queued_messages
|
|
140
|
-
or None,
|
|
141
|
-
) as client:
|
|
142
|
-
self._client = client
|
|
199
|
+
# 使用已创建的 Client(在 connect() 中创建)
|
|
200
|
+
# async with 会连接,退出时会自动断开
|
|
201
|
+
async with self._client:
|
|
143
202
|
self._connected = True # 修复 P0-2:标记为已连接
|
|
144
|
-
logger.success(
|
|
145
|
-
f"MQTT 连接成功 - {self.config.broker_host}:{self.config.broker_port}"
|
|
146
|
-
)
|
|
147
203
|
|
|
148
204
|
# 重置重连计数
|
|
149
205
|
attempt = 0
|
|
@@ -162,7 +218,6 @@ class MQTTClient:
|
|
|
162
218
|
|
|
163
219
|
except aiomqtt.MqttError as e:
|
|
164
220
|
logger.error(f"MQTT 连接失败: {e}")
|
|
165
|
-
self._client = None
|
|
166
221
|
self._connected = False # 修复 P0-2:标记为未连接
|
|
167
222
|
|
|
168
223
|
# 检查重连次数限制
|
|
@@ -190,14 +245,14 @@ class MQTTClient:
|
|
|
190
245
|
await asyncio.sleep(interval)
|
|
191
246
|
|
|
192
247
|
async def _message_loop(self):
|
|
193
|
-
"""
|
|
248
|
+
"""消息接收循环
|
|
194
249
|
|
|
195
|
-
使用 async for
|
|
250
|
+
使用 async for 迭代消息,将消息放入队列由 workers 处理
|
|
196
251
|
|
|
197
|
-
|
|
198
|
-
-
|
|
199
|
-
-
|
|
200
|
-
-
|
|
252
|
+
关键改进:
|
|
253
|
+
- 接收和处理分离:避免高吞吐下任务爆炸
|
|
254
|
+
- 有界队列:maxsize 可配置(默认 100k),作为保险丝
|
|
255
|
+
- 队列满时阻塞:形成自然背压,CPU/延迟会变明显(扩容信号)
|
|
201
256
|
|
|
202
257
|
异常处理:
|
|
203
258
|
- asyncio.CancelledError:任务被取消,退出循环
|
|
@@ -208,19 +263,9 @@ class MQTTClient:
|
|
|
208
263
|
return
|
|
209
264
|
|
|
210
265
|
try:
|
|
211
|
-
# region 调用代码溯源(aiomqtt.Client.messages)
|
|
212
|
-
# aiomqtt.Client.messages 是一个异步迭代器
|
|
213
|
-
# 内部调用:
|
|
214
|
-
# paho.mqtt.client.Client._handle_on_message()
|
|
215
|
-
# → 将消息放入队列
|
|
216
|
-
# → aiomqtt 从队列中取出消息
|
|
217
|
-
# GitHub:https://github.com/sbtinstruments/aiomqtt/blob/main/aiomqtt/client.py#L400
|
|
218
|
-
# endregion
|
|
219
266
|
async for message in self._client.messages:
|
|
220
|
-
#
|
|
221
|
-
|
|
222
|
-
self._handle_message(message), name=f"handle_{message.topic}"
|
|
223
|
-
)
|
|
267
|
+
# 将消息放入队列(阻塞等待,形成自然背压)
|
|
268
|
+
await self._message_queue.put(message)
|
|
224
269
|
except asyncio.CancelledError:
|
|
225
270
|
logger.info("消息处理任务已取消")
|
|
226
271
|
raise
|
|
@@ -230,138 +275,163 @@ class MQTTClient:
|
|
|
230
275
|
except Exception as e:
|
|
231
276
|
logger.exception(f"消息循环异常: {e}")
|
|
232
277
|
|
|
233
|
-
async def
|
|
234
|
-
"""
|
|
235
|
-
Args:
|
|
236
|
-
message: aiomqtt.Message 对象
|
|
278
|
+
async def _worker(self, worker_id: int):
|
|
279
|
+
"""消息处理 Worker
|
|
237
280
|
|
|
238
|
-
|
|
239
|
-
1. 检查 payload 大小
|
|
240
|
-
2. 解码 UTF-8
|
|
241
|
-
3. 解析 JSON
|
|
242
|
-
4. 使用 protocol.parse_message() 验证和解析
|
|
243
|
-
5. 路由到对应处理器
|
|
244
|
-
"""
|
|
281
|
+
从队列中取消息并处理,支持并发控制
|
|
245
282
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if len(message.payload) > self.config.max_payload_size:
|
|
249
|
-
logger.warning(
|
|
250
|
-
f"Payload 过大,已忽略 - "
|
|
251
|
-
f"topic: {message.topic}, size: {len(message.payload)}"
|
|
252
|
-
)
|
|
253
|
-
return
|
|
283
|
+
Args:
|
|
284
|
+
worker_id: Worker ID(用于日志)
|
|
254
285
|
|
|
286
|
+
设计要点:
|
|
287
|
+
- 并发上限可控(默认 16 个 worker)
|
|
288
|
+
- 异常隔离(worker 崩溃不影响其他 worker)
|
|
289
|
+
- 优雅退出(_running=False 时退出)
|
|
290
|
+
"""
|
|
291
|
+
while self._running:
|
|
255
292
|
try:
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
return
|
|
293
|
+
# 从队列取消息(带超时,避免无法退出) asyncio.Queue 是原子安全的
|
|
294
|
+
message = await asyncio.wait_for(self._message_queue.get(), timeout=1.0)
|
|
295
|
+
# 处理消息
|
|
296
|
+
await self._handle_message(message)
|
|
297
|
+
except asyncio.TimeoutError:
|
|
298
|
+
# 队列为空,继续等待
|
|
299
|
+
continue
|
|
300
|
+
except asyncio.CancelledError:
|
|
301
|
+
# Worker {worker_id} 被取消
|
|
302
|
+
break
|
|
303
|
+
except Exception as e:
|
|
304
|
+
logger.exception(f"Worker {worker_id} 异常: {e}")
|
|
305
|
+
# 继续运行,不退出
|
|
270
306
|
|
|
271
|
-
|
|
272
|
-
|
|
307
|
+
def subscribe(self, pattern: str, handler: Callable[[str, bytes], Any]):
|
|
308
|
+
"""订阅原始消息 (bytes)
|
|
273
309
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
handler
|
|
310
|
+
Args:
|
|
311
|
+
pattern: MQTT topic pattern (支持通配符 +/#)
|
|
312
|
+
handler: 回调函数
|
|
313
|
+
- 签名: async def handler(topic: str, payload: bytes)
|
|
314
|
+
- topic: 实际收到消息的 topic
|
|
315
|
+
- payload: 原始 bytes 数据 (未解码)
|
|
277
316
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
await handler(topic_str, msg)
|
|
282
|
-
else:
|
|
283
|
-
handler(topic_str, msg)
|
|
284
|
-
else:
|
|
285
|
-
# 修复 P0-3:RPC 请求无处理器时,立即返回错误响应(防止调用方超时)
|
|
286
|
-
from .protocol import RPCRequest, RPCResponse
|
|
317
|
+
并发行为:
|
|
318
|
+
- 同一 pattern 的多个 handlers 按注册顺序**顺序调用**(非并发)
|
|
319
|
+
- 如需并发处理,请在 handler 内部使用 asyncio.create_task()
|
|
287
320
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
request_id=msg.request_id,
|
|
295
|
-
error=f"No RPC handler registered for topic: {topic_str}",
|
|
296
|
-
)
|
|
297
|
-
await self.publish(
|
|
298
|
-
msg.reply_to, json.dumps(error_response.to_dict()), qos=1
|
|
299
|
-
)
|
|
300
|
-
else:
|
|
301
|
-
logger.debug(f"收到消息(无处理器)- topic: {topic_str}")
|
|
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]
|
|
302
327
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
328
|
+
# 立即向 MQTT broker 订阅
|
|
329
|
+
if self._client:
|
|
330
|
+
# 使用 asyncio.create_task 避免阻塞
|
|
331
|
+
asyncio.create_task(self._client.subscribe(pattern))
|
|
307
332
|
|
|
308
|
-
|
|
309
|
-
|
|
333
|
+
self._raw_handlers[pattern].append(handler)
|
|
334
|
+
self._subscriptions.add(pattern)
|
|
310
335
|
|
|
311
|
-
|
|
312
|
-
- ✅ P0-1: 未连接时不崩溃,队列化订阅
|
|
336
|
+
logger.debug(f"订阅已注册 - pattern: {pattern}")
|
|
313
337
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
签名:async def handler(topic: str, message: RPCRequest | RPCResponse)
|
|
318
|
-
或:def handler(topic: str, message: RPCRequest | RPCResponse)
|
|
338
|
+
@property
|
|
339
|
+
def raw(self) -> aiomqtt.Client:
|
|
340
|
+
"""暴露底层 aiomqtt.Client,用于高级用法
|
|
319
341
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
- 如果已连接,立即订阅
|
|
323
|
-
- 如果未连接,等待连接后自动订阅
|
|
342
|
+
使用场景:
|
|
343
|
+
await client.raw.publish(topic, payload, qos=1, retain=False)
|
|
324
344
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
345
|
+
Raises:
|
|
346
|
+
RuntimeError: 客户端未连接
|
|
347
|
+
"""
|
|
348
|
+
if not self._client:
|
|
349
|
+
raise RuntimeError("Client not connected")
|
|
350
|
+
return self._client
|
|
329
351
|
|
|
330
|
-
|
|
352
|
+
def unsubscribe(self, pattern: str, handler: Optional[Callable] = None):
|
|
353
|
+
"""取消订阅
|
|
331
354
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
355
|
+
Args:
|
|
356
|
+
pattern: MQTT topic pattern
|
|
357
|
+
handler: 要移除的 handler(None = 移除所有)
|
|
335
358
|
|
|
336
|
-
|
|
359
|
+
注意:
|
|
360
|
+
- 当某个 pattern 的最后一个 handler 被移除时:
|
|
361
|
+
- 若当前已连接,会向 broker 发送 MQTT UNSUBSCRIBE
|
|
362
|
+
- 无论是否连接,都会清理本地 matcher/handlers
|
|
337
363
|
"""
|
|
338
|
-
|
|
339
|
-
|
|
364
|
+
if pattern not in self._raw_handlers:
|
|
365
|
+
logger.debug(f"取消订阅失败:pattern 不存在 - {pattern}")
|
|
366
|
+
return
|
|
340
367
|
|
|
341
|
-
|
|
342
|
-
if handler:
|
|
343
|
-
self._message_handlers[topic] = handler
|
|
368
|
+
should_broker_unsubscribe = False
|
|
344
369
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
|
348
376
|
else:
|
|
349
|
-
|
|
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}")
|
|
402
|
+
|
|
403
|
+
async def _handle_message(self, message: aiomqtt.Message):
|
|
404
|
+
"""处理单条消息(传输层,只处理 bytes)
|
|
350
405
|
|
|
351
|
-
|
|
352
|
-
|
|
406
|
+
核心改变:
|
|
407
|
+
1. 不再解析 JSON/协议
|
|
408
|
+
2. 将 payload (bytes) 分发给 raw handlers
|
|
409
|
+
3. 不再区分 RPC/Event(让上层处理)
|
|
353
410
|
|
|
354
411
|
Args:
|
|
355
|
-
|
|
412
|
+
message: aiomqtt.Message 对象
|
|
356
413
|
"""
|
|
357
|
-
|
|
414
|
+
topic_str = str(message.topic)
|
|
415
|
+
payload = message.payload # bytes 类型
|
|
416
|
+
|
|
417
|
+
# 检查 payload 大小(防御 DoS)
|
|
418
|
+
if len(payload) > self.config.max_payload_size:
|
|
419
|
+
logger.warning(
|
|
420
|
+
f"Payload 过大,已忽略 - topic: {topic_str}, size: {len(payload)}"
|
|
421
|
+
)
|
|
358
422
|
return
|
|
359
423
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
424
|
+
# === 唯一职责:将 bytes 分发给所有匹配的 handlers ===
|
|
425
|
+
# MQTTMatcher.iter_match 返回值列表,不是 (pattern, handlers) 元组
|
|
426
|
+
for handlers in self._raw_matcher.iter_match(topic_str):
|
|
427
|
+
for handler in handlers:
|
|
428
|
+
try:
|
|
429
|
+
if asyncio.iscoroutinefunction(handler):
|
|
430
|
+
await handler(topic_str, payload) # 传递 bytes!
|
|
431
|
+
else:
|
|
432
|
+
handler(topic_str, payload)
|
|
433
|
+
except Exception as e:
|
|
434
|
+
logger.exception(f"Handler 异常 - topic: {topic_str}, error: {e}")
|
|
365
435
|
|
|
366
436
|
async def _restore_subscriptions(self):
|
|
367
437
|
"""恢复所有订阅(重连后调用)
|
|
@@ -375,32 +445,18 @@ class MQTTClient:
|
|
|
375
445
|
if not self._subscriptions:
|
|
376
446
|
return
|
|
377
447
|
|
|
378
|
-
|
|
448
|
+
# 创建快照,避免遍历时被修改(修复 P0-C)
|
|
449
|
+
topics = list(self._subscriptions)
|
|
450
|
+
logger.info(f"恢复 {len(topics)} 个订阅...")
|
|
379
451
|
|
|
380
|
-
for topic in
|
|
381
|
-
|
|
452
|
+
for topic in topics:
|
|
453
|
+
try:
|
|
454
|
+
await self._client.subscribe(topic)
|
|
455
|
+
except aiomqtt.MqttError as e:
|
|
456
|
+
logger.error(f"恢复订阅失败 - topic: {topic}, error: {e}")
|
|
382
457
|
|
|
383
458
|
logger.success("订阅恢复完成")
|
|
384
459
|
|
|
385
|
-
async def publish(self, topic: str, payload: str, qos: int = 0):
|
|
386
|
-
"""发布消息
|
|
387
|
-
Args:
|
|
388
|
-
topic: 目标主题
|
|
389
|
-
payload: 消息载荷(字符串)
|
|
390
|
-
qos: QoS 等级(0/1/2)
|
|
391
|
-
|
|
392
|
-
异常:
|
|
393
|
-
aiomqtt.MqttError: 发布失败
|
|
394
|
-
"""
|
|
395
|
-
if not self._client:
|
|
396
|
-
logger.warning(f"发布失败:客户端未连接 - topic: {topic}")
|
|
397
|
-
return
|
|
398
|
-
|
|
399
|
-
try:
|
|
400
|
-
await self._client.publish(topic, payload, qos=qos)
|
|
401
|
-
except aiomqtt.MqttError as e:
|
|
402
|
-
logger.error(f"发布失败 - topic: {topic}, error: {e}")
|
|
403
|
-
|
|
404
460
|
def _create_tls_context(self) -> ssl.SSLContext:
|
|
405
461
|
"""创建 TLS 上下文
|
|
406
462
|
|
mqttxx/config.py
CHANGED
|
@@ -119,6 +119,16 @@ class MQTTConfig:
|
|
|
119
119
|
reconnect: 重连配置
|
|
120
120
|
max_queued_messages: 最大排队消息数(0=无限)
|
|
121
121
|
max_payload_size: 最大消息载荷大小(字节,防止 DoS 攻击)
|
|
122
|
+
message_queue_maxsize: 消息队列大小限制(默认 100,000)
|
|
123
|
+
- 语义:"几乎无限",仅作保险丝防止 OOM
|
|
124
|
+
- 队列满时行为:阻塞等待(背压信号)
|
|
125
|
+
- 触发背压时:CPU/延迟升高 → 扩容信号
|
|
126
|
+
- Python 字面量分隔符:100_000 = 100000(下划线仅用于可读性)
|
|
127
|
+
num_workers: Worker 数量(默认 None = CPU核数×2)
|
|
128
|
+
- None(默认):CPU核数 × 2(适合 IO-bound 负载)
|
|
129
|
+
- 自定义值:根据 handler 类型调整
|
|
130
|
+
- CPU-bound handler:设为 CPU核数
|
|
131
|
+
- IO-bound handler:设为 CPU核数 × 2~4
|
|
122
132
|
log_level: 日志级别(DEBUG|INFO|WARNING|ERROR)
|
|
123
133
|
|
|
124
134
|
示例:
|
|
@@ -165,6 +175,10 @@ class MQTTConfig:
|
|
|
165
175
|
max_queued_messages: int = 0 # 0 = 无限
|
|
166
176
|
max_payload_size: int = 1024 * 1024 # 1MB
|
|
167
177
|
|
|
178
|
+
# 消息处理(修复 P0-A)
|
|
179
|
+
message_queue_maxsize: int = 100_000 # 10W 条 队列最大容量(保险丝)
|
|
180
|
+
num_workers: Optional[int] = None # Worker 数量(None = CPU核数×2)
|
|
181
|
+
|
|
168
182
|
# 日志级别
|
|
169
183
|
log_level: str = "INFO"
|
|
170
184
|
|