220 lines
8.2 KiB
Python
220 lines
8.2 KiB
Python
"""
|
||
AI Box Sophon Adapter — MQTT 客户端能力组件
|
||
|
||
从插件 __init__.py 抽取的三个外部 MQTT 客户端启动块,统一模式:
|
||
- enabled=False → 仅警告"未启用"并返回 None
|
||
- enabled=True → 懒加载客户端类,启动失败降级为 None(仅警告,不阻断插件)
|
||
|
||
内部 MQTT 主题约定(勿改):
|
||
与盒子侧约定一致(盒子只设根主题 box,子路径固定 aibox_sophon/...);
|
||
勿用 chongming/{插件}/{操作} 保留命名空间——网关 MQTT Bridge 订阅了
|
||
chongming/+/+,会拦截 3 级主题并按 Worker RPC 路由,而 edge_app_controller
|
||
不是 @rpc_method(Worker 无对应 Queryable),导致 worker_manager 报
|
||
"Worker ... 无回复,尝试重试"。
|
||
"""
|
||
import logging
|
||
from typing import Any
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 插件包名(作为 importlib 探测的基准,兼容 plugins.xxx 与 xxx 两种导入路径)
|
||
_PKG = __package__ or "plugins.aibox_sophon_adapter"
|
||
|
||
|
||
def _import_first(candidates: list) -> Any:
|
||
"""按序尝试导入模块,全部失败返回 None。"""
|
||
import importlib
|
||
|
||
for name in candidates:
|
||
try:
|
||
return importlib.import_module(name)
|
||
except ImportError:
|
||
continue
|
||
return None
|
||
|
||
|
||
def build_subscribe_routes(topics_cfg: list) -> list:
|
||
"""
|
||
根据配置的订阅主题列表构建 MQTTSubscribeClient 路由。
|
||
|
||
topics_cfg: [{"topic": "...", "handler": "board_ping", "reply_topic": "..."}, ...]
|
||
- topic: 订阅主题(支持 + / # 通配符)
|
||
- handler: 处理函数名,按序从插件 router 模块、插件包自身查找
|
||
- reply_topic: 可选,回复主题;处理后返回值将发布到该主题(支持 {1}{2} 占位符)
|
||
|
||
适配约定(按首个位置参数类型):
|
||
- Pydantic 模型: 用 payload 构造模型实例后调用(如 board_ping -> BoardPingRequest(**payload))
|
||
- dict: 直接传整个 payload 和 topic(如 edge_app_controller(payload, topic))
|
||
- 其他: 将 payload 作为关键字参数展开调用
|
||
|
||
返回: [{"topic": ..., "handler": async callable(payload, topic), "reply_topic": ...}]
|
||
"""
|
||
from typing import get_origin
|
||
|
||
from pydantic import BaseModel
|
||
|
||
# 注意: 不能用 "from .. import router" 取子模块,因为插件包属性 router
|
||
# 已被 routes.router(FastAPI APIRouter)遮蔽,故用 importlib 按名导入。
|
||
pkg_base = _PKG
|
||
pkg_leaf = _PKG.rsplit(".", 1)[-1]
|
||
router_mod = _import_first([f"{pkg_base}.router", f"{pkg_leaf}.router"])
|
||
pkg_mod = _import_first([pkg_base, pkg_leaf])
|
||
|
||
routes = []
|
||
for t in topics_cfg or []:
|
||
topic = t.get("topic", "")
|
||
handler_name = t.get("handler", "")
|
||
reply_topic = t.get("reply_topic")
|
||
if not topic or not handler_name:
|
||
logger.warning("[MQTTSubscribe] 跳过无效订阅项: %s", t)
|
||
continue
|
||
|
||
fn = None
|
||
if router_mod is not None:
|
||
fn = getattr(router_mod, handler_name, None)
|
||
if fn is None and pkg_mod is not None:
|
||
fn = getattr(pkg_mod, handler_name, None)
|
||
if fn is None:
|
||
logger.warning(
|
||
"[MQTTSubscribe] 未知 handler '%s',跳过 %s", handler_name, topic
|
||
)
|
||
continue
|
||
|
||
# 探测首个位置参数的类型注解
|
||
hints = getattr(fn, "__annotations__", {})
|
||
first_type: Any = None
|
||
for pname, ptype in hints.items():
|
||
if pname in ("worker_id", "return"):
|
||
continue
|
||
first_type = ptype
|
||
break
|
||
|
||
if (
|
||
isinstance(first_type, type)
|
||
and issubclass(first_type, BaseModel)
|
||
):
|
||
model_cls = first_type
|
||
|
||
async def _cb_model(data, topic, _fn=fn, _model=model_cls):
|
||
return await _fn(_model(**data))
|
||
routes.append({
|
||
"topic": topic,
|
||
"handler": _cb_model,
|
||
"reply_topic": reply_topic,
|
||
})
|
||
elif first_type is dict or get_origin(first_type) is dict:
|
||
# 处理函数期望整个 dict 作为第一个参数(如 edge_app_controller(payload, topic))
|
||
async def _cb_payload(data, topic, _fn=fn):
|
||
return await _fn(data, topic)
|
||
routes.append({
|
||
"topic": topic,
|
||
"handler": _cb_payload,
|
||
"reply_topic": reply_topic,
|
||
})
|
||
else:
|
||
# 其他签名:将 payload 按关键字参数展开调用
|
||
async def _cb_kwargs(data, topic, _fn=fn):
|
||
if isinstance(data, dict):
|
||
return await _fn(**data)
|
||
return await _fn()
|
||
routes.append({
|
||
"topic": topic,
|
||
"handler": _cb_kwargs,
|
||
"reply_topic": reply_topic,
|
||
})
|
||
|
||
return routes
|
||
|
||
|
||
async def start_mqtt_forward(cfg: dict) -> Any:
|
||
"""启动外部 MQTT 转发客户端,返回客户端或 None。"""
|
||
enabled = cfg.get("enabled", False)
|
||
if not enabled:
|
||
logger.warning("[MQTTForward] mqtt_forward 未启用,配置内容: %s", cfg)
|
||
return None
|
||
|
||
from chongming_mqtt.mqtt_forward import MQTTForwardClient
|
||
|
||
uri = cfg.get("uri", "")
|
||
topic = cfg.get("topic", "/board_ping")
|
||
logger.warning(
|
||
"[MQTTForward] 配置已加载: enabled=%s, uri=%s, topic=%s", enabled, uri, topic
|
||
)
|
||
if not uri:
|
||
return None
|
||
|
||
client = MQTTForwardClient(uri=uri, topic=topic)
|
||
try:
|
||
await client.start()
|
||
logger.warning("[MQTTForward] 外部转发已启用: %s → %s", uri, topic)
|
||
return client
|
||
except Exception as e:
|
||
logger.warning("[MQTTForward] 启动失败,将跳过转发: %s", e)
|
||
return None
|
||
|
||
|
||
async def start_mqtt_subscribe(cfg: dict) -> Any:
|
||
"""启动外部 MQTT 订阅客户端(本插件直接订阅外部 Broker,不走网关 Bridge)。"""
|
||
enabled = cfg.get("enabled", False)
|
||
if not enabled:
|
||
logger.warning("[MQTTSubscribe] mqtt_subscribe 未启用,配置内容: %s", cfg)
|
||
return None
|
||
|
||
from chongming_mqtt.mqtt_subscribe import MQTTSubscribeClient
|
||
|
||
uri = cfg.get("uri", "")
|
||
topics = cfg.get("topics", []) or []
|
||
logger.warning(
|
||
"[MQTTSubscribe] 配置已加载: enabled=%s, uri=%s, topics=%s",
|
||
enabled, uri, topics,
|
||
)
|
||
if not (uri and topics):
|
||
logger.warning("[MQTTSubscribe] 未配置 uri 或 topics,跳过外部订阅")
|
||
return None
|
||
|
||
routes = build_subscribe_routes(topics)
|
||
client = MQTTSubscribeClient(uri=uri, routes=routes)
|
||
try:
|
||
await client.start()
|
||
logger.warning("[MQTTSubscribe] 外部订阅已启用: %s", uri)
|
||
return client
|
||
except Exception as e:
|
||
logger.warning("[MQTTSubscribe] 启动失败,将跳过订阅: %s", e)
|
||
return None
|
||
|
||
|
||
async def start_internal_mqtt(cfg: dict) -> tuple[Any, str, str]:
|
||
"""
|
||
启动内部 MQTT 请求-响应客户端
|
||
(handler 内对任意应用进程发起请求-响应,主题显式传入,默认连内部 127.0.0.1:1883)。
|
||
|
||
返回 (client | None, request_topic, response_topic):
|
||
主题无论是否启用都按配置解析并返回(由调用方写入 global_var)。
|
||
"""
|
||
req_topic = cfg.get(
|
||
"request_topic", "box/aibox_sophon/edge_app_controller"
|
||
)
|
||
resp_topic = cfg.get(
|
||
"response_topic", "box/aibox_sophon/edge_app_controller_reply"
|
||
)
|
||
enabled = cfg.get("enabled", False)
|
||
if not enabled:
|
||
logger.warning("[InternalMQTT] internal_mqtt 未启用,配置内容: %s", cfg)
|
||
return None, req_topic, resp_topic
|
||
|
||
from chongming_mqtt.internal_mqtt import InternalMQTTClient
|
||
|
||
uri = cfg.get("uri", "mqtt://127.0.0.1:1883")
|
||
logger.warning(
|
||
"[InternalMQTT] 配置已加载: enabled=%s, uri=%s, request_topic=%s, response_topic=%s",
|
||
enabled, uri, req_topic, resp_topic,
|
||
)
|
||
client = InternalMQTTClient(uri=uri)
|
||
try:
|
||
await client.start()
|
||
logger.warning("[InternalMQTT] 内部请求-响应客户端已启用: %s", uri)
|
||
return client, req_topic, resp_topic
|
||
except Exception as e:
|
||
logger.warning("[InternalMQTT] 启动失败,将跳过: %s", e)
|
||
return None, req_topic, resp_topic
|