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 +81 -0
- mqttxx/client.py +459 -0
- mqttxx/config.py +190 -0
- mqttxx/conventions.py +145 -0
- mqttxx/exceptions.py +160 -0
- mqttxx/protocol.py +201 -0
- mqttxx/rpc.py +417 -0
- mqttxx-2.0.2.dist-info/LICENSE +21 -0
- mqttxx-2.0.2.dist-info/METADATA +490 -0
- mqttxx-2.0.2.dist-info/RECORD +12 -0
- mqttxx-2.0.2.dist-info/WHEEL +5 -0
- mqttxx-2.0.2.dist-info/top_level.txt +1 -0
mqttxx/rpc.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
# MQTT RPC 模块 - 基于 aiomqtt 的双向对等 RPC 调用
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any, Callable, Optional
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
from .client import MQTTClient
|
|
10
|
+
from .config import RPCConfig
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
RPCTimeoutError,
|
|
13
|
+
RPCRemoteError,
|
|
14
|
+
TooManyConcurrentCallsError,
|
|
15
|
+
MQTTXError,
|
|
16
|
+
ErrorCode,
|
|
17
|
+
)
|
|
18
|
+
from .protocol import RPCRequest, RPCResponse
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# 权限检查回调类型
|
|
22
|
+
AuthCallback = Callable[[str, str, RPCRequest], bool]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RPCManager:
|
|
26
|
+
"""RPC 调用管理器(基于 aiomqtt 客户端)
|
|
27
|
+
|
|
28
|
+
核心改进(相对 gmqtt 版本):
|
|
29
|
+
1. 适配纯 async/await 模式(不依赖 EventEmitter)
|
|
30
|
+
2. 权限控制(auth_callback 参数,修复 P0-4)
|
|
31
|
+
3. 并发限制(max_concurrent_calls)
|
|
32
|
+
4. 注销机制(unregister 方法,修复 P0-3)
|
|
33
|
+
5. 方法拆分(降低复杂度)
|
|
34
|
+
|
|
35
|
+
职责:
|
|
36
|
+
- 发起远程 RPC 调用(通过 call 方法)
|
|
37
|
+
- 注册本地方法供远程调用(通过 register 装饰器)
|
|
38
|
+
- 处理 RPC 请求和响应消息
|
|
39
|
+
- 管理并发调用的 Future 对象
|
|
40
|
+
|
|
41
|
+
设计决策:
|
|
42
|
+
- 不再依赖 EventEmitter,直接注册消息处理器到 MQTTClient
|
|
43
|
+
- 使用 handle_rpc_message 作为消息处理入口
|
|
44
|
+
- 权限检查在请求处理前进行(可拒绝执行)
|
|
45
|
+
|
|
46
|
+
示例:
|
|
47
|
+
# 创建 RPC 管理器(带权限控制)
|
|
48
|
+
async def auth_check(caller_id, method, request):
|
|
49
|
+
if method in ["delete_user"]:
|
|
50
|
+
return caller_id in ADMIN_LIST
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
rpc = RPCManager(client, auth_callback=auth_check)
|
|
54
|
+
|
|
55
|
+
# 注册本地方法
|
|
56
|
+
@rpc.register("get_status")
|
|
57
|
+
async def get_status(params):
|
|
58
|
+
return {"status": "online", "temperature": 25.5}
|
|
59
|
+
|
|
60
|
+
# 订阅 RPC 主题并绑定处理器
|
|
61
|
+
client.subscribe(
|
|
62
|
+
"my/rpc/topic",
|
|
63
|
+
lambda t, m: rpc.handle_rpc_message(t, m)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# 远程调用
|
|
67
|
+
result = await rpc.call(
|
|
68
|
+
topic="remote/rpc/topic",
|
|
69
|
+
method="get_status",
|
|
70
|
+
reply_to="my/rpc/topic"
|
|
71
|
+
)
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
client: MQTTClient,
|
|
77
|
+
config: Optional[RPCConfig] = None,
|
|
78
|
+
auth_callback: Optional[AuthCallback] = None
|
|
79
|
+
):
|
|
80
|
+
"""初始化 RPC 管理器
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
client: MQTTClient 实例(用于底层消息收发)
|
|
84
|
+
config: RPC 配置(可选,默认使用标准配置)
|
|
85
|
+
auth_callback: 权限检查回调函数(可选)
|
|
86
|
+
签名:async def auth_callback(caller_id: str, method: str, request: RPCRequest) -> bool
|
|
87
|
+
返回:True = 允许,False = 拒绝
|
|
88
|
+
|
|
89
|
+
使用示例:
|
|
90
|
+
client = MQTTClient(...)
|
|
91
|
+
await client.connect()
|
|
92
|
+
|
|
93
|
+
# 基础用法(无权限控制)
|
|
94
|
+
rpc = RPCManager(client)
|
|
95
|
+
|
|
96
|
+
# 带权限控制
|
|
97
|
+
async def auth_check(caller_id, method, request):
|
|
98
|
+
return caller_id in ALLOWED_CLIENTS
|
|
99
|
+
|
|
100
|
+
rpc = RPCManager(client, auth_callback=auth_check)
|
|
101
|
+
"""
|
|
102
|
+
self._client = client
|
|
103
|
+
self.config = config or RPCConfig()
|
|
104
|
+
self._auth_callback = auth_callback
|
|
105
|
+
|
|
106
|
+
# RPC 状态
|
|
107
|
+
self._pending_calls: dict[str, asyncio.Future] = {} # request_id → Future
|
|
108
|
+
self._handlers: dict[str, Callable] = {} # method_name → handler
|
|
109
|
+
self._pending_calls_lock = asyncio.Lock() # 修复 P0-1:保护 _pending_calls 并发访问
|
|
110
|
+
|
|
111
|
+
logger.info("RPCManager 已初始化")
|
|
112
|
+
|
|
113
|
+
def register(self, method_name: str):
|
|
114
|
+
"""装饰器:注册本地 RPC 方法供远程调用
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
method_name: 方法名称(远程节点通过此名称调用)
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
装饰器函数
|
|
121
|
+
|
|
122
|
+
使用示例:
|
|
123
|
+
@rpc.register("get_status")
|
|
124
|
+
async def get_status(params):
|
|
125
|
+
return {"status": "online", "temperature": 25.5}
|
|
126
|
+
|
|
127
|
+
@rpc.register("process_command")
|
|
128
|
+
def process_command(params):
|
|
129
|
+
# 同步方法也支持
|
|
130
|
+
return {"result": "ok"}
|
|
131
|
+
"""
|
|
132
|
+
def decorator(func: Callable):
|
|
133
|
+
self._handlers[method_name] = func
|
|
134
|
+
logger.success(f"RPC 方法已注册: {method_name}")
|
|
135
|
+
return func
|
|
136
|
+
|
|
137
|
+
return decorator
|
|
138
|
+
|
|
139
|
+
def unregister(self, method_name: str):
|
|
140
|
+
"""注销 RPC 方法
|
|
141
|
+
|
|
142
|
+
修复点:
|
|
143
|
+
- ✅ P0-3: 提供注销机制,防止内存泄漏
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
method_name: 方法名称
|
|
147
|
+
|
|
148
|
+
使用示例:
|
|
149
|
+
rpc.unregister("get_status")
|
|
150
|
+
"""
|
|
151
|
+
if method_name in self._handlers:
|
|
152
|
+
del self._handlers[method_name]
|
|
153
|
+
logger.info(f"RPC 方法已注销: {method_name}")
|
|
154
|
+
|
|
155
|
+
def handle_rpc_message(self, topic: str, message: RPCRequest | RPCResponse):
|
|
156
|
+
"""处理 RPC 消息(由 MQTTClient 调用)
|
|
157
|
+
|
|
158
|
+
这是 RPC 消息的处理入口,替代 gmqtt 版本的 EventEmitter.emit
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
topic: MQTT 主题
|
|
162
|
+
message: RPC 请求或响应消息
|
|
163
|
+
|
|
164
|
+
使用示例:
|
|
165
|
+
# 订阅主题并绑定 RPC 处理器
|
|
166
|
+
client.subscribe(
|
|
167
|
+
"my/rpc/topic",
|
|
168
|
+
lambda t, m: rpc.handle_rpc_message(t, m)
|
|
169
|
+
)
|
|
170
|
+
"""
|
|
171
|
+
if isinstance(message, RPCRequest):
|
|
172
|
+
asyncio.create_task(
|
|
173
|
+
self._handle_request(topic, message),
|
|
174
|
+
name=f"rpc_req_{message.request_id[:8]}"
|
|
175
|
+
)
|
|
176
|
+
elif isinstance(message, RPCResponse):
|
|
177
|
+
asyncio.create_task(
|
|
178
|
+
self._handle_response(topic, message),
|
|
179
|
+
name=f"rpc_resp_{message.request_id[:8]}"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
async def call(
|
|
183
|
+
self,
|
|
184
|
+
topic: str,
|
|
185
|
+
method: str,
|
|
186
|
+
params: Any = None,
|
|
187
|
+
timeout: Optional[float] = None,
|
|
188
|
+
reply_to: str = None,
|
|
189
|
+
) -> Any:
|
|
190
|
+
"""远程调用 RPC 方法
|
|
191
|
+
|
|
192
|
+
修复点:
|
|
193
|
+
- ✅ 新增并发限制检查
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
topic: 目标 MQTT 主题(例如:bots/456)
|
|
197
|
+
method: 远程方法名
|
|
198
|
+
params: 方法参数(可选)
|
|
199
|
+
timeout: 超时时间(秒,None 则使用配置的默认值)
|
|
200
|
+
reply_to: 响应主题(必需,例如:server/device_123)
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
远程方法的返回值
|
|
204
|
+
|
|
205
|
+
Raises:
|
|
206
|
+
MQTTXError: 客户端未连接
|
|
207
|
+
ValueError: reply_to 参数缺失
|
|
208
|
+
TooManyConcurrentCallsError: 并发调用超限
|
|
209
|
+
RPCTimeoutError: 调用超时
|
|
210
|
+
RPCRemoteError: 远程执行失败
|
|
211
|
+
|
|
212
|
+
使用示例:
|
|
213
|
+
# 基础调用
|
|
214
|
+
result = await rpc.call(
|
|
215
|
+
topic="bots/456",
|
|
216
|
+
method="get_status",
|
|
217
|
+
reply_to="server/device_123"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# 带参数调用
|
|
221
|
+
result = await rpc.call(
|
|
222
|
+
topic="bots/456",
|
|
223
|
+
method="process_command",
|
|
224
|
+
params={"command": "restart"},
|
|
225
|
+
reply_to="server/device_123",
|
|
226
|
+
timeout=60.0
|
|
227
|
+
)
|
|
228
|
+
"""
|
|
229
|
+
if not self._client.is_connected:
|
|
230
|
+
raise MQTTXError("MQTT 客户端未连接", ErrorCode.NOT_CONNECTED)
|
|
231
|
+
|
|
232
|
+
if reply_to is None:
|
|
233
|
+
raise ValueError("reply_to 参数是必需的,不能为 None")
|
|
234
|
+
|
|
235
|
+
# 生成请求
|
|
236
|
+
request_id = str(uuid.uuid4())
|
|
237
|
+
timeout = timeout or self.config.default_timeout
|
|
238
|
+
|
|
239
|
+
request = RPCRequest(
|
|
240
|
+
request_id=request_id,
|
|
241
|
+
method=method,
|
|
242
|
+
params=params,
|
|
243
|
+
reply_to=reply_to,
|
|
244
|
+
caller_id=self._client.config.client_id,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# 修复 P0-1:原子性检查并发限制 + 注册 Future
|
|
248
|
+
async with self._pending_calls_lock:
|
|
249
|
+
if len(self._pending_calls) >= self.config.max_concurrent_calls:
|
|
250
|
+
raise TooManyConcurrentCallsError(
|
|
251
|
+
f"并发 RPC 调用超限: {len(self._pending_calls)}/{self.config.max_concurrent_calls}"
|
|
252
|
+
)
|
|
253
|
+
# 创建 Future
|
|
254
|
+
future = asyncio.get_event_loop().create_future()
|
|
255
|
+
self._pending_calls[request_id] = future
|
|
256
|
+
|
|
257
|
+
# 发送请求
|
|
258
|
+
await self._client.publish(topic, json.dumps(request.to_dict()), qos=1)
|
|
259
|
+
logger.debug(f"RPC 请求已发送 - method: {method}, request_id: {request_id[:8]}")
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
# 等待响应
|
|
263
|
+
result = await asyncio.wait_for(future, timeout=timeout)
|
|
264
|
+
logger.debug(f"RPC 调用成功 - method: {method}")
|
|
265
|
+
return result
|
|
266
|
+
except asyncio.TimeoutError:
|
|
267
|
+
logger.error(f"RPC 超时 - method: {method}, timeout: {timeout}s")
|
|
268
|
+
raise RPCTimeoutError(f"RPC 调用超时: {method}")
|
|
269
|
+
finally:
|
|
270
|
+
# 清理 Future(修复 P0-1:使用锁保护)
|
|
271
|
+
async with self._pending_calls_lock:
|
|
272
|
+
self._pending_calls.pop(request_id, None)
|
|
273
|
+
|
|
274
|
+
async def _handle_response(self, topic: str, message: RPCResponse):
|
|
275
|
+
"""处理 RPC 响应
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
topic: MQTT 主题
|
|
279
|
+
message: RPC 响应消息
|
|
280
|
+
"""
|
|
281
|
+
# 修复 P0-1:使用锁保护读取
|
|
282
|
+
async with self._pending_calls_lock:
|
|
283
|
+
future = self._pending_calls.get(message.request_id)
|
|
284
|
+
|
|
285
|
+
if not future or future.done():
|
|
286
|
+
logger.warning(f"收到未知/过期响应 - request_id: {message.request_id[:8]}")
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
if message.error:
|
|
290
|
+
future.set_exception(RPCRemoteError(message.error))
|
|
291
|
+
else:
|
|
292
|
+
future.set_result(message.result)
|
|
293
|
+
|
|
294
|
+
async def _handle_request(self, topic: str, message: RPCRequest):
|
|
295
|
+
"""处理 RPC 请求
|
|
296
|
+
|
|
297
|
+
修复点:
|
|
298
|
+
- ✅ P0-4: 添加权限检查
|
|
299
|
+
- ✅ 重构为子方法(降低复杂度)
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
topic: MQTT 主题
|
|
303
|
+
message: RPC 请求消息
|
|
304
|
+
|
|
305
|
+
执行流程:
|
|
306
|
+
1. 权限检查(如果配置了 auth_callback)
|
|
307
|
+
2. 查找处理器
|
|
308
|
+
3. 执行方法
|
|
309
|
+
4. 发送响应
|
|
310
|
+
"""
|
|
311
|
+
# 权限检查
|
|
312
|
+
if self._auth_callback:
|
|
313
|
+
allowed = await self._check_permission(message)
|
|
314
|
+
if not allowed:
|
|
315
|
+
logger.warning(
|
|
316
|
+
f"RPC 权限拒绝 - caller: {message.caller_id}, method: {message.method}"
|
|
317
|
+
)
|
|
318
|
+
response = RPCResponse(
|
|
319
|
+
request_id=message.request_id,
|
|
320
|
+
error="Permission denied"
|
|
321
|
+
)
|
|
322
|
+
await self._send_response(message.reply_to, response)
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
# 查找处理器
|
|
326
|
+
handler = self._handlers.get(message.method)
|
|
327
|
+
|
|
328
|
+
if not handler:
|
|
329
|
+
logger.warning(f"方法未找到 - method: {message.method}")
|
|
330
|
+
response = RPCResponse(
|
|
331
|
+
request_id=message.request_id,
|
|
332
|
+
error=f"方法未找到: {message.method}"
|
|
333
|
+
)
|
|
334
|
+
else:
|
|
335
|
+
# 执行方法
|
|
336
|
+
response = await self._execute_handler(handler, message)
|
|
337
|
+
|
|
338
|
+
# 发送响应
|
|
339
|
+
await self._send_response(message.reply_to, response)
|
|
340
|
+
|
|
341
|
+
async def _check_permission(self, message: RPCRequest) -> bool:
|
|
342
|
+
"""检查 RPC 调用权限
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
message: RPC 请求消息
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
True = 允许,False = 拒绝
|
|
349
|
+
|
|
350
|
+
异常处理:
|
|
351
|
+
- 如果 auth_callback 抛出异常,默认拒绝(fail-safe)
|
|
352
|
+
"""
|
|
353
|
+
try:
|
|
354
|
+
if asyncio.iscoroutinefunction(self._auth_callback):
|
|
355
|
+
allowed = await self._auth_callback(
|
|
356
|
+
message.caller_id,
|
|
357
|
+
message.method,
|
|
358
|
+
message
|
|
359
|
+
)
|
|
360
|
+
else:
|
|
361
|
+
allowed = self._auth_callback(
|
|
362
|
+
message.caller_id,
|
|
363
|
+
message.method,
|
|
364
|
+
message
|
|
365
|
+
)
|
|
366
|
+
return bool(allowed)
|
|
367
|
+
except Exception as e:
|
|
368
|
+
logger.exception(f"权限检查失败: {e}")
|
|
369
|
+
return False # 默认拒绝
|
|
370
|
+
|
|
371
|
+
async def _execute_handler(self, handler: Callable, message: RPCRequest) -> RPCResponse:
|
|
372
|
+
"""执行 RPC 方法处理器
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
handler: 注册的方法处理器
|
|
376
|
+
message: RPC 请求消息
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
RPC 响应消息(包含结果或错误)
|
|
380
|
+
|
|
381
|
+
异常处理:
|
|
382
|
+
- asyncio.CancelledError: 向上传播(任务被取消)
|
|
383
|
+
- 其他异常: 捕获并封装到 RPCResponse.error
|
|
384
|
+
"""
|
|
385
|
+
try:
|
|
386
|
+
# 自动处理同步/异步函数
|
|
387
|
+
if asyncio.iscoroutinefunction(handler):
|
|
388
|
+
result = await handler(message.params)
|
|
389
|
+
else:
|
|
390
|
+
result = handler(message.params)
|
|
391
|
+
|
|
392
|
+
logger.debug(f"RPC 方法执行成功 - method: {message.method}")
|
|
393
|
+
return RPCResponse(
|
|
394
|
+
request_id=message.request_id,
|
|
395
|
+
result=result
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
except asyncio.CancelledError:
|
|
399
|
+
# 任务被取消,向上传播
|
|
400
|
+
raise
|
|
401
|
+
|
|
402
|
+
except Exception as e:
|
|
403
|
+
logger.exception(f"RPC 方法执行失败 - method: {message.method}")
|
|
404
|
+
return RPCResponse(
|
|
405
|
+
request_id=message.request_id,
|
|
406
|
+
error=str(e)
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
async def _send_response(self, topic: str, response: RPCResponse):
|
|
410
|
+
"""发送 RPC 响应
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
topic: 响应主题
|
|
414
|
+
response: RPC 响应消息
|
|
415
|
+
"""
|
|
416
|
+
await self._client.publish(topic, json.dumps(response.to_dict()), qos=1)
|
|
417
|
+
logger.debug(f"RPC 响应已发送 - request_id: {response.request_id[:8]}")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 MQTTX Team
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|