first commit
This commit is contained in:
+72
@@ -0,0 +1,72 @@
|
|||||||
|
/target
|
||||||
|
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
bin/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
include/
|
||||||
|
man/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
pip-selfcheck.json
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
|
||||||
|
# Mr Developer
|
||||||
|
.mr.developer.cfg
|
||||||
|
.project
|
||||||
|
.pydevproject
|
||||||
|
|
||||||
|
# Rope
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# VSCode
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Pyenv
|
||||||
|
.python-version
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# chongming-mqtt
|
||||||
|
|
||||||
|
MQTT 工具包,封装基于 `amqtt` 的三类客户端,覆盖常见 MQTT 通信场景。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv pip install -e utils/mqtt
|
||||||
|
```
|
||||||
|
|
||||||
|
依赖:`amqtt>=0.11.3`,Python `>=3.12`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 客户端一览
|
||||||
|
|
||||||
|
| 客户端 | 方向 | 用途 |
|
||||||
|
| --------------------- | ----------- | ---------------------------------------------------------- |
|
||||||
|
| `MQTTForwardClient` | 单向发布 | 主动把数据上报/转发到外部 Broker |
|
||||||
|
| `MQTTSubscribeClient` | 订阅 + 回复 | 订阅外部主题,处理请求并按 `reply_topic` 回复(请求-响应) |
|
||||||
|
| `InternalMQTTClient` | 请求-响应 | 对任意应用进程发起 MQTT 请求-响应,显式指定请求/响应主题 |
|
||||||
|
|
||||||
|
三者均支持:
|
||||||
|
|
||||||
|
- 断线自动重连(无限重试,最大重连间隔 30s)
|
||||||
|
- URI percent-encoded 凭据解码(密码中的 `@` 写为 `%40`)
|
||||||
|
- `mqtt://user:pass@host:port` 连接串
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. MQTTForwardClient — 外部转发(单向发布)
|
||||||
|
|
||||||
|
主动把数据推送到外部 Broker 的指定主题。
|
||||||
|
|
||||||
|
```python
|
||||||
|
from chongming_mqtt import MQTTForwardClient
|
||||||
|
|
||||||
|
client = MQTTForwardClient(uri="mqtt://admin:Mixc%402021@host:48081", topic="/board_ping")
|
||||||
|
await client.start()
|
||||||
|
await client.publish({"BoardId": "B001", "Temp": 45.6}) # 发布到默认主题
|
||||||
|
await client.publish({"x": 1}, topic="/other_topic") # 指定主题
|
||||||
|
await client.stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
| ------------------------------ | ------------------------------------- |
|
||||||
|
| `start()` | 建立长连接(自动重连) |
|
||||||
|
| `publish(payload, topic=None)` | 发布 JSON;`topic` 缺省用构造时的主题 |
|
||||||
|
| `stop()` | 断开连接 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. MQTTSubscribeClient — 外部订阅(请求-响应)
|
||||||
|
|
||||||
|
订阅外部主题,收到消息后调用绑定的处理函数;配置 `reply_topic` 后,把处理函数返回值发布到回复主题。
|
||||||
|
|
||||||
|
```python
|
||||||
|
from chongming_mqtt import MQTTSubscribeClient
|
||||||
|
|
||||||
|
async def handler(payload: dict, topic: str):
|
||||||
|
# 处理请求,返回回复内容(返回 None 表示不回复)
|
||||||
|
return {"code": 0, "msg": "ok", "data": payload}
|
||||||
|
|
||||||
|
client = MQTTSubscribeClient(
|
||||||
|
uri="mqtt://admin:Mixc%402021@host:48081",
|
||||||
|
routes=[
|
||||||
|
{
|
||||||
|
"topic": "/edge_app_controller", # 订阅主题(支持 + / # 通配符)
|
||||||
|
"handler": handler, # async (payload, topic) -> dict | None
|
||||||
|
"reply_topic": "/edge_app_controller_reply", # 回复主题(支持 {1}{2} 占位符)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
await client.start()
|
||||||
|
# ... 运行中 ...
|
||||||
|
await client.stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
- **不回复约定**:处理函数返回 `None` 时不发布回复(静默命令)
|
||||||
|
- **`reply_topic` 占位符**:`{1}`、`{2}`… 填充订阅主题通配符捕获值,如 `reply_topic="/dev/{1}/resp"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. InternalMQTTClient — 内部请求-响应
|
||||||
|
|
||||||
|
对任意应用进程发起 MQTT 请求-响应:**显式指定"请求主题"与"响应主题"**,等待并返回响应报文。不依赖特定协议(如 `chongming/*`),适用于与其它应用/进程通信。
|
||||||
|
|
||||||
|
```python
|
||||||
|
from chongming_mqtt import InternalMQTTClient
|
||||||
|
|
||||||
|
client = InternalMQTTClient(uri="mqtt://127.0.0.1:1883") # 默认连内部 broker
|
||||||
|
await client.start()
|
||||||
|
|
||||||
|
resp = await client.request(
|
||||||
|
request_topic="/other_app/request", # 目标应用监听的请求主题
|
||||||
|
response_topic="/other_app/reply/#", # 目标应用回复的主题(支持 +/# 或 {rid})
|
||||||
|
payload={"BoardId": "B001"},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
# resp: dict | None(None 表示超时/异常)
|
||||||
|
await client.stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应关联策略
|
||||||
|
|
||||||
|
请求会自动注入 `request_id` 字段。收到响应时按以下优先级匹配:
|
||||||
|
|
||||||
|
1. **对方回显 `request_id`** → 精确匹配(推荐)
|
||||||
|
2. `response_topic` 含 **`{rid}` 模板** → 订阅唯一主题匹配(对方把 request_id 拼进响应主题)
|
||||||
|
3. 兜底 **FIFO**(固定主题、不回显,串行场景)
|
||||||
|
|
||||||
|
支持并发请求与响应乱序,互不串扰。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与 aibox_sophon_adapter 插件的集成
|
||||||
|
|
||||||
|
插件在 `[workers.config]` 下分别配置这三个客户端:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[workers.config.mqtt_forward] # -> MQTTForwardClient
|
||||||
|
enabled = true
|
||||||
|
uri = "mqtt://admin:Mixc%402021@dg.datagoo.cn:48081"
|
||||||
|
topic = "/board_ping"
|
||||||
|
|
||||||
|
[workers.config.mqtt_subscribe] # -> MQTTSubscribeClient
|
||||||
|
enabled = true
|
||||||
|
uri = "mqtt://admin:Mixc%402021@dg.datagoo.cn:48081"
|
||||||
|
[[workers.config.mqtt_subscribe.topics]]
|
||||||
|
topic = "/edge_app_controller"
|
||||||
|
handler = "edge_app_controller"
|
||||||
|
reply_topic = "/edge_app_controller_reply"
|
||||||
|
|
||||||
|
[workers.config.internal_mqtt] # -> InternalMQTTClient(handler 内调用)
|
||||||
|
enabled = true
|
||||||
|
uri = "mqtt://127.0.0.1:1883"
|
||||||
|
```
|
||||||
|
|
||||||
|
handler 内使用 `InternalMQTTClient`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# edge_app_controller.py
|
||||||
|
from chongming_mqtt import InternalMQTTClient
|
||||||
|
|
||||||
|
async def _cmd_some_op(payload: dict) -> dict:
|
||||||
|
from aibox_sophon_adapter.global_var import internal_mqtt
|
||||||
|
if internal_mqtt is None:
|
||||||
|
return {"error": "内部 MQTT 未初始化"}
|
||||||
|
resp = await internal_mqtt.request(
|
||||||
|
request_topic="/other_app/request",
|
||||||
|
response_topic="/other_app/reply/#",
|
||||||
|
payload={"BoardId": payload.get("BoardId")},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
return resp or {"error": "内部请求超时"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 构建集成
|
||||||
|
|
||||||
|
若通过 PyInstaller/Nuitka 打包,需在 `config.toml` 的 `[pyinstaller_build_import] base` 中加入:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
"chongming_mqtt",
|
||||||
|
```
|
||||||
|
|
||||||
|
并确保 `chongming_mqtt` 已安装到目标 Python 环境(`uv pip install -e utils/mqtt`)。
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[build-system]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
requires = ["hatchling"]
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "chongming-mqtt"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "chongming MQTT 工具包:外部转发、外部订阅、内部请求-响应"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"amqtt>=0.11.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/chongming_mqtt"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
chongming_mqtt - MQTT 工具包
|
||||||
|
|
||||||
|
提供三类 MQTT 客户端:
|
||||||
|
|
||||||
|
- ``MQTTForwardClient`` 外部 MQTT 转发客户端(单向发布,主动上报数据)
|
||||||
|
- ``MQTTSubscribeClient`` 外部 MQTT 订阅客户端(订阅主题 + 请求-响应,收外部请求并回复)
|
||||||
|
- ``InternalMQTTClient`` 内部 MQTT 请求-响应客户端(handler 内对任意应用进程
|
||||||
|
发起请求,显式指定请求/响应主题)
|
||||||
|
"""
|
||||||
|
from chongming_mqtt.mqtt_forward import MQTTForwardClient
|
||||||
|
from chongming_mqtt.mqtt_subscribe import MQTTSubscribeClient
|
||||||
|
from chongming_mqtt.internal_mqtt import InternalMQTTClient
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MQTTForwardClient",
|
||||||
|
"MQTTSubscribeClient",
|
||||||
|
"InternalMQTTClient",
|
||||||
|
]
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"""
|
||||||
|
AI Box Sophon Adapter — 内部 MQTT 请求-响应客户端
|
||||||
|
|
||||||
|
建立到内部(或目标)MQTT Broker 的长连接,提供通用请求-响应能力:
|
||||||
|
调用方**显式传入"请求主题"与"响应主题"**,与任意应用进程通信
|
||||||
|
(不依赖 chongming 协议,响应主题由对方应用发布)。
|
||||||
|
|
||||||
|
支持:
|
||||||
|
- 断线自动重连(无限重试)
|
||||||
|
- URI percent-encoded 凭据解码(如密码中的 @ 写为 %40)
|
||||||
|
- 请求时自动注入 request_id 用于关联回复(对方回显则按 request_id 匹配)
|
||||||
|
- 响应主题支持通配符(+ / #)或 {rid} 模板(对方把 request_id 拼进响应主题)
|
||||||
|
- 每个请求独立超时控制,并发安全
|
||||||
|
|
||||||
|
使用方式:
|
||||||
|
client = InternalMQTTClient(uri="mqtt://127.0.0.1:1883")
|
||||||
|
await client.start()
|
||||||
|
resp = await client.request(
|
||||||
|
request_topic="/app/request",
|
||||||
|
response_topic="/app/reply/#", # 或 "/app/reply/{rid}"
|
||||||
|
payload={"key": "value"},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
await client.stop()
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from urllib.parse import urlparse, unquote
|
||||||
|
|
||||||
|
from amqtt.client import MQTTClient
|
||||||
|
from amqtt.mqtt.constants import QOS_1
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 关联 ID 字段名(注入请求负载,供对方回显匹配)
|
||||||
|
RID_KEY = "request_id"
|
||||||
|
|
||||||
|
|
||||||
|
class InternalMQTTClient:
|
||||||
|
"""
|
||||||
|
内部 MQTT 请求-响应客户端
|
||||||
|
|
||||||
|
连接目标 MQTT Broker(默认内部 127.0.0.1:1883),
|
||||||
|
提供 request() 方法:发布到显式指定的请求主题,订阅显式指定的响应主题,
|
||||||
|
等待并返回响应报文。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, uri: str = "mqtt://127.0.0.1:1883"):
|
||||||
|
self._uri = uri
|
||||||
|
self._client: MQTTClient | None = None
|
||||||
|
self._loop_task: asyncio.Task | None = None
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
# 关联状态
|
||||||
|
self._pending: dict[str, asyncio.Future] = {} # rid -> future
|
||||||
|
self._rid_topics: dict[str, str] = {} # rid -> 唯一订阅主题({rid} 模板)
|
||||||
|
self._subscribed: set[str] = set() # 已订阅主题(避免重复订阅)
|
||||||
|
|
||||||
|
# 预解析 URI 并解码凭据(amqtt 的 urlparse 不会自动 unquote)
|
||||||
|
parsed = urlparse(uri)
|
||||||
|
self._clean_uri = f"{parsed.scheme}://{parsed.hostname}:{parsed.port or 1883}"
|
||||||
|
self._username: str | None = unquote(parsed.username) if parsed.username else None
|
||||||
|
self._password: str | None = unquote(parsed.password) if parsed.password else None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""建立到目标 MQTT Broker 的连接"""
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._client = MQTTClient(
|
||||||
|
config={
|
||||||
|
"auto_reconnect": True,
|
||||||
|
"reconnect_retries": -1, # 无限重连
|
||||||
|
"reconnect_max_interval": 30, # 最大重连间隔(秒)
|
||||||
|
"keep_alive": 60,
|
||||||
|
"connection_timeout": 10, # 连接超时(秒),避免永久挂起
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# 手动初始化 session 并设置解码后的凭据(绕过 amqtt urlparse 不解码 %40 的问题)
|
||||||
|
self._client.session = self._client._init_session(self._clean_uri)
|
||||||
|
if self._username:
|
||||||
|
self._client.session.username = self._username
|
||||||
|
if self._password:
|
||||||
|
self._client.session.password = self._password
|
||||||
|
await self._client._do_connect()
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._loop_task = asyncio.create_task(self._loop())
|
||||||
|
logger.info(f"[InternalMQTT] 已连接: {self._clean_uri}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[InternalMQTT] 连接失败: {e}")
|
||||||
|
if self._client:
|
||||||
|
try:
|
||||||
|
await self._client.disconnect()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._client = None
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""断开连接,并让所有挂起请求返回异常"""
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
if self._client:
|
||||||
|
try:
|
||||||
|
await self._client.disconnect()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[InternalMQTT] 断开连接时异常: {e}")
|
||||||
|
finally:
|
||||||
|
self._client = None
|
||||||
|
if self._loop_task and not self._loop_task.done():
|
||||||
|
self._loop_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._loop_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for fut in self._pending.values():
|
||||||
|
if not fut.done():
|
||||||
|
fut.set_exception(RuntimeError("内部 MQTT 客户端已停止"))
|
||||||
|
self._pending.clear()
|
||||||
|
self._rid_topics.clear()
|
||||||
|
self._subscribed.clear()
|
||||||
|
logger.info("[InternalMQTT] 已断开连接")
|
||||||
|
|
||||||
|
async def request(
|
||||||
|
self,
|
||||||
|
request_topic: str,
|
||||||
|
response_topic: str,
|
||||||
|
payload: dict,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
) -> dict | None:
|
||||||
|
"""
|
||||||
|
发起 MQTT 请求-响应(显式指定请求/响应主题)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_topic: 要发布请求的目标主题(对方应用订阅处理)
|
||||||
|
response_topic: 要监听的响应主题;
|
||||||
|
支持 + / # 通配符;若含 {rid} 模板则替换为本次 request_id
|
||||||
|
(对方把 request_id 拼进响应主题时使用)
|
||||||
|
payload: 请求报文(会自动注入 request_id 字段)
|
||||||
|
timeout: 等待回复的超时(秒)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
响应报文(dict);超时或异常返回 None
|
||||||
|
"""
|
||||||
|
if not self._running or self._client is None:
|
||||||
|
logger.warning("[InternalMQTT] 客户端未连接,跳过请求")
|
||||||
|
return None
|
||||||
|
|
||||||
|
rid = uuid.uuid4().hex
|
||||||
|
req_payload = dict(payload)
|
||||||
|
req_payload[RID_KEY] = rid
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
fut: asyncio.Future = loop.create_future()
|
||||||
|
self._pending[rid] = fut
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1) 解析响应主题:{rid} 模板 → 唯一主题;否则原样(固定/通配,共享订阅)
|
||||||
|
resp_topic = response_topic
|
||||||
|
if "{rid}" in response_topic:
|
||||||
|
resp_topic = response_topic.replace("{rid}", rid)
|
||||||
|
self._rid_topics[rid] = resp_topic
|
||||||
|
await self._subscribe_once(resp_topic)
|
||||||
|
|
||||||
|
# 2) 发布请求
|
||||||
|
data = json.dumps(req_payload, ensure_ascii=False, default=str)
|
||||||
|
await self._client.publish(request_topic, data.encode("utf-8"), QOS_1)
|
||||||
|
logger.debug(
|
||||||
|
f"[InternalMQTT] 请求 {rid} -> {request_topic},响应订阅: {resp_topic}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3) 等待回复(超时)
|
||||||
|
return await asyncio.wait_for(fut, timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
f"[InternalMQTT] 请求 {rid} 超时({timeout}s): {request_topic}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[InternalMQTT] 请求 {rid} 异常: {e}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
self._pending.pop(rid, None)
|
||||||
|
self._rid_topics.pop(rid, None)
|
||||||
|
|
||||||
|
async def _subscribe_once(self, topic: str) -> None:
|
||||||
|
"""订阅主题(已订阅则跳过,供多个并发请求共享同一响应主题)"""
|
||||||
|
if self._client is None:
|
||||||
|
return
|
||||||
|
if topic in self._subscribed:
|
||||||
|
return
|
||||||
|
await self._client.subscribe([(topic, QOS_1)])
|
||||||
|
self._subscribed.add(topic)
|
||||||
|
|
||||||
|
async def _loop(self) -> None:
|
||||||
|
"""响应消息监听循环"""
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
m = await self._client.deliver_message() # type: ignore
|
||||||
|
if m is None:
|
||||||
|
break
|
||||||
|
await self._handle(str(m.topic), m.data)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
if self._running:
|
||||||
|
logger.error(f"[InternalMQTT] 接收异常: {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
async def _handle(self, topic: str, payload: bytes) -> None:
|
||||||
|
"""解析响应,按 request_id / 响应主题匹配挂起的请求"""
|
||||||
|
try:
|
||||||
|
data = json.loads(bytes(payload).decode("utf-8"))
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
logger.debug(f"[InternalMQTT] 响应非 JSON,忽略: {topic}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1) 对方回显 request_id → 直接匹配
|
||||||
|
rid = data.get(RID_KEY)
|
||||||
|
if rid and rid in self._pending:
|
||||||
|
fut = self._pending[rid]
|
||||||
|
if not fut.done():
|
||||||
|
fut.set_result(data)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2) {rid} 模板唯一主题 → 按主题匹配
|
||||||
|
for r, t in self._rid_topics.items():
|
||||||
|
if t == topic and r in self._pending:
|
||||||
|
fut = self._pending[r]
|
||||||
|
if not fut.done():
|
||||||
|
fut.set_result(data)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3) 兜底:对方不回显 request_id,按 FIFO 匹配最早挂起的请求
|
||||||
|
if self._pending:
|
||||||
|
rid0 = next(iter(self._pending))
|
||||||
|
fut = self._pending[rid0]
|
||||||
|
if not fut.done():
|
||||||
|
logger.debug(
|
||||||
|
f"[InternalMQTT] 无 request_id,按 FIFO 匹配 {rid0}(topic={topic})"
|
||||||
|
)
|
||||||
|
fut.set_result(data)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self._running
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""
|
||||||
|
AI Box Sophon Adapter — 外部 MQTT 转发客户端
|
||||||
|
|
||||||
|
建立到外部 MQTT Broker 的长连接,将设备心跳数据转发到指定主题。
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from urllib.parse import urlparse, unquote
|
||||||
|
|
||||||
|
from amqtt.client import MQTTClient
|
||||||
|
from amqtt.mqtt.constants import QOS_1
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MQTTForwardClient:
|
||||||
|
"""
|
||||||
|
外部 MQTT 转发客户端
|
||||||
|
|
||||||
|
在插件启动时建立到外部 MQTT Broker 的持久连接,
|
||||||
|
提供 publish() 方法将数据转发到指定主题。
|
||||||
|
|
||||||
|
支持 URI 中的 percent-encoded 凭据(如密码中的 @ 写为 %40)。
|
||||||
|
|
||||||
|
使用方式:
|
||||||
|
client = MQTTForwardClient(uri="mqtt://user:pass@host:port")
|
||||||
|
await client.start()
|
||||||
|
await client.publish("/topic", {"key": "value"})
|
||||||
|
await client.stop()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, uri: str, topic: str = "/board_ping"):
|
||||||
|
self._uri = uri
|
||||||
|
self._topic = topic
|
||||||
|
self._client: MQTTClient | None = None
|
||||||
|
self._running = False
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# 预解析 URI 并解码凭据(amqtt 的 urlparse 不会自动 unquote)
|
||||||
|
parsed = urlparse(uri)
|
||||||
|
self._clean_uri = f"{parsed.scheme}://{parsed.hostname}:{parsed.port or 1883}"
|
||||||
|
self._username: str | None = unquote(parsed.username) if parsed.username else None
|
||||||
|
self._password: str | None = unquote(parsed.password) if parsed.password else None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""建立到外部 MQTT Broker 的连接"""
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._client = MQTTClient(
|
||||||
|
config={
|
||||||
|
"auto_reconnect": True,
|
||||||
|
"reconnect_retries": -1, # 无限重连
|
||||||
|
"reconnect_max_interval": 30, # 最大重连间隔(秒)
|
||||||
|
"keep_alive": 60,
|
||||||
|
"connection_timeout": 10, # 连接超时(秒),避免永久挂起
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# 手动初始化 session 并设置解码后的凭据(绕过 amqtt urlparse 不解码 %40 的问题)
|
||||||
|
self._client.session = self._client._init_session(self._clean_uri)
|
||||||
|
if self._username:
|
||||||
|
self._client.session.username = self._username
|
||||||
|
if self._password:
|
||||||
|
self._client.session.password = self._password
|
||||||
|
await self._client._do_connect()
|
||||||
|
self._running = True
|
||||||
|
logger.info(f"[MQTTForward] 已连接到外部 Broker: {self._clean_uri}")
|
||||||
|
logger.info(f"[MQTTForward] 转发目标主题: {self._topic}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[MQTTForward] 连接外部 Broker 失败: {e}")
|
||||||
|
self._client = None
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""断开与外部 MQTT Broker 的连接"""
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
if self._client:
|
||||||
|
try:
|
||||||
|
await self._client.disconnect()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[MQTTForward] 断开连接时异常: {e}")
|
||||||
|
finally:
|
||||||
|
self._client = None
|
||||||
|
logger.info("[MQTTForward] 已断开外部 Broker 连接")
|
||||||
|
|
||||||
|
async def publish(self, payload: dict, topic: str | None = None) -> bool:
|
||||||
|
"""
|
||||||
|
发布消息到外部 MQTT Broker
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: 要发送的 JSON 数据
|
||||||
|
topic: 目标主题,默认使用初始化时配置的主题
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否发送成功
|
||||||
|
"""
|
||||||
|
if not self._running or self._client is None:
|
||||||
|
logger.warning("[MQTTForward] 客户端未连接,跳过转发")
|
||||||
|
return False
|
||||||
|
|
||||||
|
target = topic or self._topic
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
try:
|
||||||
|
data = json.dumps(payload, ensure_ascii=False, default=str)
|
||||||
|
await self._client.publish(target, data.encode("utf-8"), QOS_1)
|
||||||
|
logger.debug(f"[MQTTForward] 已转发到 {target}: {len(data)} 字节")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[MQTTForward] 发布失败 [{target}]: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self._running
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"""
|
||||||
|
AI Box Sophon Adapter — 外部 MQTT 订阅客户端
|
||||||
|
|
||||||
|
建立到外部 MQTT Broker 的长连接,订阅指定主题,
|
||||||
|
收到消息后按主题匹配调用绑定的处理函数(插件内直接调用,不走网关 Bridge)。
|
||||||
|
|
||||||
|
支持:
|
||||||
|
- 断线自动重连(无限重试)
|
||||||
|
- URI percent-encoded 凭据解码(如密码中的 @ 写为 %40)
|
||||||
|
- 多个订阅主题,主题支持 +(单级)/ #(多级,须在末尾)通配符
|
||||||
|
- 每个主题绑定一个处理函数,收到消息后直接调用
|
||||||
|
- 请求-响应:配置 reply_topic 后,将处理函数返回值发布到回复主题
|
||||||
|
(reply_topic 支持 {1} {2}... 占位符,填充订阅主题通配符捕获值)
|
||||||
|
|
||||||
|
使用方式:
|
||||||
|
client = MQTTSubscribeClient(
|
||||||
|
uri="mqtt://user:pass@host:port",
|
||||||
|
routes=[{"topic": "/edge_app_controller",
|
||||||
|
"handler": async_fn,
|
||||||
|
"reply_topic": "/edge_app_controller_reply"}],
|
||||||
|
)
|
||||||
|
await client.start()
|
||||||
|
await client.stop()
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse, unquote
|
||||||
|
|
||||||
|
from amqtt.client import MQTTClient
|
||||||
|
from amqtt.mqtt.constants import QOS_1
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 处理函数类型: async def handler(payload: dict, topic: str) -> Any
|
||||||
|
Handler = Callable[[dict, str], Any]
|
||||||
|
|
||||||
|
|
||||||
|
def _compile_topic(topic: str) -> re.Pattern:
|
||||||
|
"""
|
||||||
|
将 MQTT 主题模板(支持 + / # 通配符)编译为正则表达式。
|
||||||
|
|
||||||
|
- ``+`` 匹配单级(编译为捕获组 ``([^/]+)``)
|
||||||
|
- ``#`` 匹配多级(编译为捕获组 ``(.*)``,须位于末尾)
|
||||||
|
"""
|
||||||
|
parts = topic.split("/")
|
||||||
|
regex = "^"
|
||||||
|
for i, part in enumerate(parts):
|
||||||
|
if i > 0:
|
||||||
|
regex += "/"
|
||||||
|
if part == "#":
|
||||||
|
regex += "(.*)"
|
||||||
|
elif part == "+":
|
||||||
|
regex += "([^/]+)"
|
||||||
|
else:
|
||||||
|
regex += re.escape(part)
|
||||||
|
regex += "$"
|
||||||
|
return re.compile(regex)
|
||||||
|
|
||||||
|
|
||||||
|
class MQTTSubscribeClient:
|
||||||
|
"""
|
||||||
|
外部 MQTT 订阅客户端
|
||||||
|
|
||||||
|
在插件启动时建立到外部 MQTT Broker 的持久连接,
|
||||||
|
订阅配置的主题,收到消息后按主题匹配调用绑定的处理函数。
|
||||||
|
|
||||||
|
处理函数约定: async def handler(payload: dict, topic: str) -> Any
|
||||||
|
若配置了 reply_topic,处理函数返回值(非 None)会发布到该回复主题。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, uri: str, routes: list | None = None):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
uri: mqtt://user:pass@host:port
|
||||||
|
routes: list[dict],每项含
|
||||||
|
topic: 订阅主题(支持 + / # 通配符)
|
||||||
|
handler: 处理函数 async callable(payload: dict, topic: str)
|
||||||
|
reply_topic: 可选,回复主题;收到消息处理后,将返回值发布到该主题
|
||||||
|
(支持 {1} {2}... 占位符)
|
||||||
|
"""
|
||||||
|
self._uri = uri
|
||||||
|
self._routes: list[tuple[str, re.Pattern, Handler, str | None]] = []
|
||||||
|
for r in routes or []:
|
||||||
|
topic = r.get("topic", "")
|
||||||
|
handler = r.get("handler")
|
||||||
|
reply_topic = r.get("reply_topic")
|
||||||
|
if topic and handler is not None:
|
||||||
|
self._routes.append((topic, _compile_topic(topic), handler, reply_topic))
|
||||||
|
self._client: MQTTClient | None = None
|
||||||
|
self._loop_task: asyncio.Task | None = None
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
# 预解析 URI 并解码凭据(amqtt 的 urlparse 不会自动 unquote)
|
||||||
|
parsed = urlparse(uri)
|
||||||
|
self._clean_uri = f"{parsed.scheme}://{parsed.hostname}:{parsed.port or 1883}"
|
||||||
|
self._username: str | None = unquote(parsed.username) if parsed.username else None
|
||||||
|
self._password: str | None = unquote(parsed.password) if parsed.password else None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""建立到外部 MQTT Broker 的连接并订阅主题"""
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not self._routes:
|
||||||
|
raise ValueError("未配置任何订阅主题 (routes 为空)")
|
||||||
|
|
||||||
|
self._client = MQTTClient(
|
||||||
|
config={
|
||||||
|
"auto_reconnect": True,
|
||||||
|
"reconnect_retries": -1, # 无限重连
|
||||||
|
"reconnect_max_interval": 30, # 最大重连间隔(秒)
|
||||||
|
"keep_alive": 60,
|
||||||
|
"connection_timeout": 10, # 连接超时(秒),避免永久挂起
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# 手动初始化 session 并设置解码后的凭据(绕过 amqtt urlparse 不解码 %40 的问题)
|
||||||
|
self._client.session = self._client._init_session(self._clean_uri)
|
||||||
|
if self._username:
|
||||||
|
self._client.session.username = self._username
|
||||||
|
if self._password:
|
||||||
|
self._client.session.password = self._password
|
||||||
|
await self._client._do_connect()
|
||||||
|
|
||||||
|
topics = [(t, QOS_1) for t, _, _, _ in self._routes]
|
||||||
|
await self._client.subscribe(topics)
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._loop_task = asyncio.create_task(self._loop())
|
||||||
|
logger.info(f"[MQTTSubscribe] 已连接到外部 Broker: {self._clean_uri}")
|
||||||
|
logger.info(f"[MQTTSubscribe] 已订阅 {len(topics)} 个主题: {[t for t, _ in topics]}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[MQTTSubscribe] 连接外部 Broker 失败: {e}")
|
||||||
|
if self._client:
|
||||||
|
try:
|
||||||
|
await self._client.disconnect()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._client = None
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""断开与外部 MQTT Broker 的连接并停止消息循环"""
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
# 先断开连接,让 deliver_message() 解锁
|
||||||
|
if self._client:
|
||||||
|
try:
|
||||||
|
await self._client.disconnect()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[MQTTSubscribe] 断开连接时异常: {e}")
|
||||||
|
finally:
|
||||||
|
self._client = None
|
||||||
|
# 再取消消息循环
|
||||||
|
if self._loop_task and not self._loop_task.done():
|
||||||
|
self._loop_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._loop_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
logger.info("[MQTTSubscribe] 已断开外部 Broker 连接")
|
||||||
|
|
||||||
|
async def _loop(self) -> None:
|
||||||
|
"""消息接收循环"""
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
m = await self._client.deliver_message() # type: ignore
|
||||||
|
if m is None:
|
||||||
|
break
|
||||||
|
await self._dispatch(str(m.topic), m.data)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
if self._running:
|
||||||
|
logger.error(f"[MQTTSubscribe] 消息接收异常: {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
async def _dispatch(self, topic: str, payload: bytes | bytearray) -> None:
|
||||||
|
"""按主题匹配,调用处理函数,并按需发布回复"""
|
||||||
|
try:
|
||||||
|
data = json.loads(bytes(payload).decode("utf-8"))
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||||
|
logger.warning(f"[MQTTSubscribe] 消息非 JSON,忽略 [{topic}]: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
for tpl, compiled, handler, reply_topic in self._routes:
|
||||||
|
matched = compiled.match(topic)
|
||||||
|
if matched:
|
||||||
|
result = None
|
||||||
|
try:
|
||||||
|
if asyncio.iscoroutinefunction(handler):
|
||||||
|
result = await handler(data, topic)
|
||||||
|
else:
|
||||||
|
result = handler(data, topic)
|
||||||
|
logger.debug(f"[MQTTSubscribe] [{tpl}] 处理完成: {topic}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[MQTTSubscribe] [{tpl}] 处理消息失败 [{topic}]: {e}")
|
||||||
|
result = None
|
||||||
|
|
||||||
|
# 请求-响应:配置了回复主题且处理函数有返回结果时,发布回复
|
||||||
|
if result is not None and reply_topic:
|
||||||
|
target = self._fill(reply_topic, matched)
|
||||||
|
ok = await self.publish(target, result)
|
||||||
|
if not ok:
|
||||||
|
logger.warning(
|
||||||
|
f"[MQTTSubscribe] 回复发布失败 [{topic}] -> {target}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug(f"[MQTTSubscribe] 无匹配 handler,忽略: {topic}")
|
||||||
|
|
||||||
|
async def publish(self, topic: str, payload: dict) -> bool:
|
||||||
|
"""
|
||||||
|
发布消息到外部 MQTT Broker(用于回复请求方)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
topic: 目标主题
|
||||||
|
payload: 要发送的 JSON 数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否发送成功
|
||||||
|
"""
|
||||||
|
if not self._running or self._client is None:
|
||||||
|
logger.warning("[MQTTSubscribe] 客户端未连接,跳过发布")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.dumps(payload, ensure_ascii=False, default=str)
|
||||||
|
await self._client.publish(topic, data.encode("utf-8"), QOS_1)
|
||||||
|
logger.debug(f"[MQTTSubscribe] 已发布到 {topic}: {len(data)} 字节")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[MQTTSubscribe] 发布失败 [{topic}]: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fill(template: str, matched: re.Match) -> str:
|
||||||
|
"""用匹配到的通配符值填充模板中的 {1} {2}... 占位符"""
|
||||||
|
if "{" not in template:
|
||||||
|
return template
|
||||||
|
for i, g in enumerate(matched.groups(), start=1):
|
||||||
|
template = template.replace("{" + str(i) + "}", g)
|
||||||
|
return template
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self._running
|
||||||
Reference in New Issue
Block a user