first commit

This commit is contained in:
2026-08-07 14:25:52 +08:00
commit 5ca88d4de8
7 changed files with 903 additions and 0 deletions
+19
View File
@@ -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",
]
+253
View File
@@ -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
+120
View File
@@ -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
+253
View File
@@ -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