mqttxx 2.0.2__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 ADDED
@@ -0,0 +1,81 @@
1
+ """MQTTX - 基于 aiomqtt 的高级 MQTT 客户端和 RPC 框架
2
+
3
+ 提供:
4
+ - MQTTClient: MQTT 连接管理(自动重连、订阅队列化、TLS/SSL 支持)
5
+ - RPCManager: 双向对等 RPC 调用(超时控制、权限检查、并发限制)
6
+ - 配置对象: MQTTConfig, TLSConfig, AuthConfig, RPCConfig 等
7
+ - 协议定义: RPCRequest, RPCResponse
8
+ - 异常系统: 统一错误码和异常层次
9
+ """
10
+
11
+ __version__ = "2.0.0"
12
+ __author__ = "MQTTX Team"
13
+
14
+ # 核心客户端
15
+ from .client import MQTTClient
16
+
17
+ # RPC 管理器
18
+ from .rpc import RPCManager
19
+ from .conventions import ConventionalRPCManager
20
+
21
+ # 配置对象
22
+ from .config import (
23
+ MQTTConfig,
24
+ TLSConfig,
25
+ AuthConfig,
26
+ ReconnectConfig,
27
+ RPCConfig,
28
+ )
29
+
30
+ # 协议定义
31
+ from .protocol import (
32
+ RPCRequest,
33
+ RPCResponse,
34
+ parse_message,
35
+ )
36
+
37
+ # 异常系统
38
+ from .exceptions import (
39
+ # 错误码
40
+ ErrorCode,
41
+ # 基础异常
42
+ MQTTXError,
43
+ ConnectionError,
44
+ MessageError,
45
+ RPCError,
46
+ # RPC 异常
47
+ RPCTimeoutError,
48
+ RPCRemoteError,
49
+ RPCMethodNotFoundError,
50
+ PermissionDeniedError,
51
+ TooManyConcurrentCallsError,
52
+ )
53
+
54
+ __all__ = [
55
+ # MQTT 客户端
56
+ "MQTTClient",
57
+ # RPC 管理器
58
+ "RPCManager",
59
+ "ConventionalRPCManager", # 约定式 RPC(强约束系统)
60
+ # 配置对象
61
+ "MQTTConfig",
62
+ "TLSConfig",
63
+ "AuthConfig",
64
+ "ReconnectConfig",
65
+ "RPCConfig",
66
+ # 协议定义
67
+ "RPCRequest",
68
+ "RPCResponse",
69
+ "parse_message",
70
+ # 异常系统
71
+ "ErrorCode",
72
+ "MQTTXError",
73
+ "ConnectionError",
74
+ "MessageError",
75
+ "RPCError",
76
+ "RPCTimeoutError",
77
+ "RPCRemoteError",
78
+ "RPCMethodNotFoundError",
79
+ "PermissionDeniedError",
80
+ "TooManyConcurrentCallsError",
81
+ ]
mqttxx/client.py ADDED
@@ -0,0 +1,459 @@
1
+ # aiomqtt 高级封装 - 基于纯 async/await 架构
2
+
3
+ import asyncio
4
+ import json
5
+ import ssl
6
+ from typing import Callable, Optional
7
+ from loguru import logger
8
+ import aiomqtt
9
+
10
+ from .config import MQTTConfig
11
+ from .exceptions import MessageError
12
+ from .protocol import parse_message
13
+
14
+
15
+ class MQTTClient:
16
+ """基于 aiomqtt 的 MQTT 客户端
17
+
18
+ 设计决策:
19
+ - aiomqtt 基于 paho-mqtt 封装,成熟稳定
20
+ - 不自动重连,需要手动实现重连循环(官方推荐模式)
21
+ - 使用 `async for message in client.messages` 异步迭代器
22
+ """
23
+
24
+ def __init__(self, config: MQTTConfig):
25
+ """初始化 MQTT 客户端
26
+
27
+ Args:
28
+ config: MQTT 配置对象
29
+
30
+ 注意:
31
+ 初始化后不会立即连接,需要调用 connect() 或使用 async with
32
+ """
33
+ self.config = config
34
+ self._client: Optional[aiomqtt.Client] = None
35
+ self._subscriptions: set[str] = set() # 订阅列表(用于重连恢复)
36
+ self._message_handlers: dict[str, Callable] = {} # topic → handler 映射
37
+ self._running = False
38
+ self._connected = False # 修复 P0-2:真实连接状态标志
39
+ self._reconnect_task: Optional[asyncio.Task] = None
40
+ self._message_task: Optional[asyncio.Task] = None
41
+
42
+ async def connect(self):
43
+ """连接到 MQTT Broker
44
+
45
+ 启动后台重连任务,自动处理连接断开和重连
46
+
47
+ """
48
+ if self._running:
49
+ logger.warning("客户端已在运行中")
50
+ return
51
+
52
+ self._running = True
53
+
54
+ self._reconnect_task = asyncio.create_task(
55
+ self._reconnect_loop(), name="mqtt_reconnect"
56
+ )
57
+
58
+ async def disconnect(self):
59
+ """断开连接并清理资源"""
60
+ self._running = False
61
+ self._connected = False # 修复 P0-2:标记为未连接
62
+
63
+ # 取消后台任务
64
+ if self._reconnect_task:
65
+ self._reconnect_task.cancel()
66
+ try:
67
+ await self._reconnect_task
68
+ except asyncio.CancelledError:
69
+ pass
70
+
71
+ # 取消消息处理任务
72
+ if self._message_task:
73
+ self._message_task.cancel()
74
+ try:
75
+ await self._message_task
76
+ except asyncio.CancelledError:
77
+ pass
78
+ if self._client:
79
+ self._client = None
80
+
81
+ logger.info("MQTT 客户端已断开")
82
+
83
+ async def __aenter__(self):
84
+ """上下文管理器入口
85
+
86
+ 示例:
87
+ async with MQTTClient(config) as client:
88
+ # 使用客户端
89
+ pass # 自动断开连接
90
+ """
91
+ await self.connect()
92
+ return self
93
+
94
+ async def __aexit__(self, exc_type, exc, tb):
95
+ """上下文管理器退出"""
96
+ await self.disconnect()
97
+
98
+ async def _reconnect_loop(self):
99
+ """重连循环(aiomqtt 核心模式)
100
+
101
+ 无限循环尝试连接 MQTT Broker,连接断开后自动重连
102
+
103
+ 重连策略:
104
+ - 初始间隔:config.reconnect.interval(默认 5 秒)
105
+ - 指数退避:每次失败后间隔乘以 backoff_multiplier
106
+ - 最大间隔:config.reconnect.max_interval(默认 60 秒)
107
+ - 最大次数:config.reconnect.max_attempts(0 = 无限)
108
+
109
+
110
+ 异常处理:
111
+ - aiomqtt.MqttError:连接/协议错误,触发重连
112
+ - asyncio.CancelledError:任务被取消,退出循环
113
+ """
114
+ attempt = 0
115
+ interval = self.config.reconnect.interval
116
+
117
+ while self._running:
118
+ try:
119
+ # 创建 TLS 上下文
120
+ tls_context = (
121
+ self._create_tls_context() if self.config.tls.enabled else None
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
143
+ self._connected = True # 修复 P0-2:标记为已连接
144
+ logger.success(
145
+ f"MQTT 连接成功 - {self.config.broker_host}:{self.config.broker_port}"
146
+ )
147
+
148
+ # 重置重连计数
149
+ attempt = 0
150
+ interval = self.config.reconnect.interval
151
+
152
+ # 恢复订阅
153
+ await self._restore_subscriptions()
154
+
155
+ # 启动消息处理任务
156
+ self._message_task = asyncio.create_task(
157
+ self._message_loop(), name="mqtt_messages"
158
+ )
159
+
160
+ # 等待消息循环结束(连接断开)
161
+ await self._message_task
162
+
163
+ except aiomqtt.MqttError as e:
164
+ logger.error(f"MQTT 连接失败: {e}")
165
+ self._client = None
166
+ self._connected = False # 修复 P0-2:标记为未连接
167
+
168
+ # 检查重连次数限制
169
+ if self.config.reconnect.max_attempts > 0:
170
+ if attempt >= self.config.reconnect.max_attempts:
171
+ logger.error("达到最大重连次数,停止重连")
172
+ break
173
+
174
+ # 计算指数退避延迟
175
+ attempt += 1
176
+ interval = min(
177
+ interval * self.config.reconnect.backoff_multiplier,
178
+ self.config.reconnect.max_interval,
179
+ )
180
+
181
+ logger.info(f"{interval:.1f}s 后重连(第 {attempt} 次)...")
182
+ await asyncio.sleep(interval)
183
+
184
+ except asyncio.CancelledError:
185
+ logger.info("重连任务已取消")
186
+ break
187
+
188
+ except Exception as e:
189
+ logger.exception(f"重连循环异常: {e}")
190
+ await asyncio.sleep(interval)
191
+
192
+ async def _message_loop(self):
193
+ """消息处理循环(aiomqtt 核心模式)
194
+
195
+ 使用 async for 迭代消息,替代 gmqtt 的 on_message 回调
196
+
197
+ 关键特性:
198
+ - 非阻塞:异步迭代器自动 yield 控制权
199
+ - 顺序处理:每条消息按顺序处理
200
+ - 并发处理:每条消息在独立 Task 中处理(不阻塞迭代器)
201
+
202
+ 异常处理:
203
+ - asyncio.CancelledError:任务被取消,退出循环
204
+ - aiomqtt.MqttError:连接断开/协议错误,重新抛出触发外层重连
205
+ - 其他异常:记录日志,不退出循环
206
+ """
207
+ if not self._client:
208
+ return
209
+
210
+ 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
+ async for message in self._client.messages:
220
+ # 异步处理消息(不阻塞迭代器)
221
+ asyncio.create_task(
222
+ self._handle_message(message), name=f"handle_{message.topic}"
223
+ )
224
+ except asyncio.CancelledError:
225
+ logger.info("消息处理任务已取消")
226
+ raise
227
+ except aiomqtt.MqttError as e:
228
+ logger.warning(f"消息循环 MQTT 错误: {e}")
229
+ raise # 关键:重新抛出,让外层重连逻辑处理
230
+ except Exception as e:
231
+ logger.exception(f"消息循环异常: {e}")
232
+
233
+ async def _handle_message(self, message: aiomqtt.Message):
234
+ """处理单条消息
235
+ Args:
236
+ message: aiomqtt.Message 对象
237
+
238
+ 消息处理流程:
239
+ 1. 检查 payload 大小
240
+ 2. 解码 UTF-8
241
+ 3. 解析 JSON
242
+ 4. 使用 protocol.parse_message() 验证和解析
243
+ 5. 路由到对应处理器
244
+ """
245
+
246
+ try:
247
+ # 检查 payload 大小(防御 DoS)
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
254
+
255
+ try:
256
+ text = message.payload.decode("utf-8")
257
+ data = json.loads(text)
258
+ except UnicodeDecodeError as e:
259
+ logger.error(
260
+ f"UTF-8 解码失败 - topic: {message.topic}, error: {e!r}, "
261
+ f"preview: {message.payload[:100]}"
262
+ )
263
+ return
264
+ except json.JSONDecodeError as e:
265
+ logger.error(
266
+ f"JSON 解析失败 - topic: {message.topic}, error: {e!r}, "
267
+ f"preview: {text[:100]}"
268
+ )
269
+ return
270
+
271
+ # 解析消息类型(使用 dataclass 替代 Box)
272
+ msg = parse_message(data)
273
+
274
+ # 路由到对应处理器
275
+ topic_str = str(message.topic)
276
+ handler = self._message_handlers.get(topic_str)
277
+
278
+ if handler:
279
+ # 调用处理器
280
+ if asyncio.iscoroutinefunction(handler):
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
287
+ if isinstance(msg, RPCRequest):
288
+ logger.warning(
289
+ f"收到 RPC 请求但无处理器 - topic: {topic_str}, method: {msg.method}"
290
+ )
291
+ # 立即发送错误响应
292
+ error_response = RPCResponse(
293
+ request_id=msg.request_id,
294
+ error=f"No RPC handler registered for topic: {topic_str}"
295
+ )
296
+ await self.publish(msg.reply_to, json.dumps(error_response.to_dict()), qos=1)
297
+ else:
298
+ logger.debug(f"收到消息(无处理器)- topic: {topic_str}")
299
+
300
+ except MessageError as e:
301
+ logger.error(f"消息解析失败: {e}")
302
+ except Exception as e:
303
+ logger.exception(f"消息处理失败: {e}")
304
+
305
+ def subscribe(self, topic: str, handler: Optional[Callable] = None):
306
+ """订阅主题
307
+
308
+ 修复点:
309
+ - ✅ P0-1: 未连接时不崩溃,队列化订阅
310
+
311
+ Args:
312
+ topic: MQTT 主题(支持通配符 +/#)
313
+ handler: 消息处理器
314
+ 签名:async def handler(topic: str, message: RPCRequest | RPCResponse)
315
+ 或:def handler(topic: str, message: RPCRequest | RPCResponse)
316
+
317
+ 设计决策:
318
+ - 记录订阅到 _subscriptions set(用于重连恢复)
319
+ - 如果已连接,立即订阅
320
+ - 如果未连接,等待连接后自动订阅
321
+
322
+ 示例:
323
+ # 同步处理器
324
+ def my_handler(topic, message):
325
+ print(f"收到消息: {topic} - {message}")
326
+
327
+ client.subscribe("test/topic", my_handler)
328
+
329
+ # 异步处理器
330
+ async def my_async_handler(topic, message):
331
+ await process_message(message)
332
+
333
+ client.subscribe("test/topic", my_async_handler)
334
+ """
335
+ # 记录订阅(用于重连恢复)
336
+ self._subscriptions.add(topic)
337
+
338
+ # 注册处理器
339
+ if handler:
340
+ self._message_handlers[topic] = handler
341
+
342
+ # 如果已连接,立即订阅
343
+ if self._client:
344
+ asyncio.create_task(self._do_subscribe(topic))
345
+ else:
346
+ logger.info(f"订阅已队列化(等待连接)- topic: {topic}")
347
+
348
+ async def _do_subscribe(self, topic: str):
349
+ """执行订阅(内部方法)
350
+
351
+ Args:
352
+ topic: MQTT 主题
353
+ """
354
+ if not self._client:
355
+ return
356
+
357
+ try:
358
+ await self._client.subscribe(topic)
359
+ logger.success(f"订阅成功 - topic: {topic}")
360
+ except aiomqtt.MqttError as e:
361
+ logger.error(f"订阅失败 - topic: {topic}, error: {e}")
362
+
363
+ async def _restore_subscriptions(self):
364
+ """恢复所有订阅(重连后调用)
365
+
366
+ 注意:
367
+ - aiomqtt 不会记录订阅列表(源码中没有 _subscriptions 存储)
368
+ - 连接成功回调(_on_connect)不会恢复订阅
369
+ - clean_session=False 只是服务器保持会话,客户端仍需手动重新订阅
370
+ - 本方法在每次重连成功后调用,手动恢复 _subscriptions 中的订阅
371
+ """
372
+ if not self._subscriptions:
373
+ return
374
+
375
+ logger.info(f"恢复 {len(self._subscriptions)} 个订阅...")
376
+
377
+ for topic in self._subscriptions:
378
+ await self._do_subscribe(topic)
379
+
380
+ logger.success("订阅恢复完成")
381
+
382
+ async def publish(self, topic: str, payload: str, qos: int = 0):
383
+ """发布消息
384
+
385
+ 修复点:
386
+ - ✅ P1-2: 未连接时记录警告
387
+
388
+ Args:
389
+ topic: 目标主题
390
+ payload: 消息载荷(字符串)
391
+ qos: QoS 等级(0/1/2)
392
+
393
+ 异常:
394
+ aiomqtt.MqttError: 发布失败
395
+ """
396
+ if not self._client:
397
+ logger.warning(f"发布失败:客户端未连接 - topic: {topic}")
398
+ return
399
+
400
+ try:
401
+ await self._client.publish(topic, payload, qos=qos)
402
+ logger.debug(f"消息已发布 - topic: {topic}, qos: {qos}")
403
+ except aiomqtt.MqttError as e:
404
+ logger.error(f"发布失败 - topic: {topic}, error: {e}")
405
+
406
+ def _create_tls_context(self) -> ssl.SSLContext:
407
+ """创建 TLS 上下文
408
+
409
+ Returns:
410
+ ssl.SSLContext 对象
411
+
412
+ 配置项:
413
+ - ca_certs: CA 证书路径
414
+ - certfile: 客户端证书路径
415
+ - keyfile: 客户端私钥路径
416
+ - verify_mode: 验证模式(CERT_REQUIRED/CERT_OPTIONAL/CERT_NONE)
417
+ - check_hostname: 是否验证主机名
418
+ """
419
+ context = ssl.create_default_context()
420
+
421
+ # 加载 CA 证书
422
+ if self.config.tls.ca_certs:
423
+ context.load_verify_locations(cafile=str(self.config.tls.ca_certs))
424
+
425
+ # 加载客户端证书(双向认证)
426
+ if self.config.tls.certfile:
427
+ context.load_cert_chain(
428
+ certfile=str(self.config.tls.certfile),
429
+ keyfile=str(self.config.tls.keyfile)
430
+ if self.config.tls.keyfile
431
+ else None,
432
+ )
433
+
434
+ # 验证模式
435
+ if self.config.tls.verify_mode == "CERT_REQUIRED":
436
+ context.check_hostname = self.config.tls.check_hostname
437
+ context.verify_mode = ssl.CERT_REQUIRED
438
+ elif self.config.tls.verify_mode == "CERT_OPTIONAL":
439
+ context.verify_mode = ssl.CERT_OPTIONAL
440
+ elif self.config.tls.verify_mode == "CERT_NONE":
441
+ context.check_hostname = False
442
+ context.verify_mode = ssl.CERT_NONE
443
+
444
+ return context
445
+
446
+ @property
447
+ def is_connected(self) -> bool:
448
+ """检查连接状态
449
+
450
+ 修复 P0-2:使用独立标志位而非对象存在性
451
+
452
+ Returns:
453
+ True = 已连接,False = 未连接
454
+
455
+ 注意:
456
+ 连接成功后 _connected 设为 True
457
+ 连接断开或失败时 _connected 设为 False
458
+ """
459
+ return self._connected