modify: mqtt额外主题

This commit is contained in:
2026-08-07 09:39:29 +08:00
parent 6037c098e9
commit 0049045ead
6 changed files with 343 additions and 11 deletions
+18
View File
@@ -203,6 +203,24 @@ async def upload(file: UploadFile = File(...)):
| 请求 | `chongming/<type>/<op>` | MQTT → Worker | | 请求 | `chongming/<type>/<op>` | MQTT → Worker |
| 响应 | `chongming/<type>/<op>/response` | Worker → MQTT(`None` 时跳过) | | 响应 | `chongming/<type>/<op>/response` | Worker → MQTT(`None` 时跳过) |
### 自定义订阅主题
除内置的 `chongming/<type>/<op>` 接口外,可将外部 MQTT 主题桥接到任意 Worker。**推荐在插件自己的配置** `config/workers/<type>/config.toml` 中通过 `[[workers.mqtt_topics]]` 声明(目标 worker 即该插件本身):
```toml
# config/workers/aibox_sophon_adapter/config.toml
[[workers.mqtt_topics]]
topic = "/board_ping" # 支持 +(单级)/ #(多级,须在末尾)通配符
subject = "aibox_sophon_adapter.board_ping" # 可选,{1} {2}... 对应通配符捕获值
response_topic = "/board_ping/response" # 可选,响应主题;默认 "<实际主题>/response"
```
- `subject` 缺省为 `<插件类型>.<topic 最后一段>`;`response_topic` 缺省为 `<实际匹配主题>/response`。
- 网关发现插件时自动收集所有 `[[workers.mqtt_topics]]`,MQTT Bridge 一并订阅并路由。
- 请求负载同样支持 `_method` / `_timeout` / `_cacheable` 控制字段。
- 多条自定义主题按配置顺序匹配,先匹配者优先;内置 `chongming/...` 接口最后兜底。
- 全局 `[<env>.mqtt].custom_topics` 仍可作为通用兜底(不依赖具体插件时使用,可选)。
### 快速测试 ### 快速测试
```bash ```bash
+5
View File
@@ -46,6 +46,11 @@ enabled = true
host = "0.0.0.0" host = "0.0.0.0"
port = 1883 port = 1883
# MQTT Bridge 额外订阅主题推荐声明在各插件自己的配置里:
# config/workers/<type>/config.toml → [[workers.mqtt_topics]]
# (如 aibox_sophon_adapter 插件的 /board_ping 心跳主题)
# 全局 [<env>.mqtt].custom_topics 仍可作为通用兜底(可选,不推荐)。
[development.mqtt.broker] [development.mqtt.broker]
# amqtt Broker 可选配置,默认允许匿名连接 # amqtt Broker 可选配置,默认允许匿名连接
# 详细参数参考: https://github.com/Yakifo/amqtt # 详细参数参考: https://github.com/Yakifo/amqtt
+10
View File
@@ -102,6 +102,15 @@ class Cors(TypedDict, total=False):
allow_headers: List[str] allow_headers: List[str]
class CustomMQTTTopic(TypedDict, total=False):
"""额外自定义 MQTT 订阅主题的路由配置"""
topic: Required[str] # 订阅主题,支持 +(单级)/ #(多级)通配符
worker: Required[str] # 目标 worker 类型
subject: str # 目标 Zenoh subject,支持 {1} {2}... 占位符;默认 "<worker>.<topic 最后一段>"
response_topic: str # 响应发布主题,支持占位符;默认 "<实际匹配主题>/response"
class MQTTConfig(TypedDict, total=False): class MQTTConfig(TypedDict, total=False):
"""MQTT Bridge 配置(按环境区分)""" """MQTT Bridge 配置(按环境区分)"""
@@ -109,6 +118,7 @@ class MQTTConfig(TypedDict, total=False):
host: str host: str
port: int port: int
broker: dict broker: dict
custom_topics: List[CustomMQTTTopic]
class ModuleSystem(TypedDict, total=False): class ModuleSystem(TypedDict, total=False):
+33 -4
View File
@@ -244,12 +244,24 @@ def _discover_plugins() -> list[dict]:
if not workers_section: if not workers_section:
continue continue
plugin_type = workers_section.get("type", plugin_type)
# 插件声明的 MQTT Bridge 额外订阅主题([[workers.mqtt_topics]])
# 网关级路由配置,不传给插件运行时;worker 目标即本插件类型。
mqtt_topics = []
for t in workers_section.get("mqtt_topics", []) or []:
if not isinstance(t, dict) or not t.get("topic"):
continue
t = dict(t)
t.setdefault("worker", plugin_type)
mqtt_topics.append(t)
# 合并运行时 config: # 合并运行时 config:
# 1. plugin_conf 顶层 (worker, worker.registration 等 gen_models 用) # 1. plugin_conf 顶层 (worker, worker.registration 等 gen_models 用)
# 2. [workers] 节的注册字段展平 (type, module, factory, cleanup, count) # 2. [workers] 节的注册字段展平 (type, module, factory, cleanup, count)
# — 其中 type 被 chongming_worker.runtime.create_workers 用于匹配 RPC 方法 # — 其中 type 被 chongming_worker.runtime.create_workers 用于匹配 RPC 方法
# 3. [workers.config] 展平到顶层 (api_key, data_dir, heartbeat 等) # 3. [workers.config] 展平到顶层 (api_key, data_dir, heartbeat 等)
workers_flat = {k: v for k, v in workers_section.items() if k != "config"} workers_flat = {k: v for k, v in workers_section.items()
if k not in ("config", "mqtt_topics")}
runtime_config = { runtime_config = {
**plugin_conf, **plugin_conf,
**workers_flat, **workers_flat,
@@ -257,11 +269,12 @@ def _discover_plugins() -> list[dict]:
} }
plugins.append({ plugins.append({
"type": workers_section.get("type", plugin_type), "type": plugin_type,
"module": workers_section.get("module", f"plugins.{plugin_type}"), "module": workers_section.get("module", f"plugins.{plugin_type}"),
"factory": workers_section.get("factory", "create_workers"), "factory": workers_section.get("factory", "create_workers"),
"cleanup": workers_section.get("cleanup", "cleanup"), "cleanup": workers_section.get("cleanup", "cleanup"),
"count": workers_section.get("count", 1), "count": workers_section.get("count", 1),
"mqtt_topics": mqtt_topics,
"config": runtime_config, "config": runtime_config,
}) })
@@ -301,6 +314,13 @@ async def load_workers(app: FastAPI, session: zenoh.Session, manager: WorkerMana
workers_conf = _discover_plugins() workers_conf = _discover_plugins()
logger.info(f"发现 {len(workers_conf)} 个 worker 插件") logger.info(f"发现 {len(workers_conf)} 个 worker 插件")
# 汇总各插件声明的 MQTT Bridge 额外订阅主题,供 setup_mqtt 使用
app.state.plugin_mqtt_topics = [
topic
for wc in workers_conf
for topic in (wc.get("mqtt_topics") or [])
]
worker_tasks = [] worker_tasks = []
custom_routers = [] custom_routers = []
for wc in workers_conf: for wc in workers_conf:
@@ -401,7 +421,9 @@ async def setup_mqtt(app: FastAPI, worker_manager: WorkerManager) -> MQTTBridge
""" """
启动 MQTT Bridge(客户端模式,Broker 由子进程管理)。 启动 MQTT Bridge(客户端模式,Broker 由子进程管理)。
从 config.toml 的环境段读取配置,若 enabled 为 false 则跳过。 从 config.toml 的环境段读取 host/port/enabled;若 enabled 为 false 则跳过。
订阅主题 = 全局 [<env>.mqtt].custom_topics(可选兜底)
+ 各插件在其 config/workers/<type>/config.toml 中声明的 [[workers.mqtt_topics]]。
使用文件锁确保多个 gunicorn worker 中只有一个启动 Bridge。 使用文件锁确保多个 gunicorn worker 中只有一个启动 Bridge。
""" """
env_config = config.get(env, {}) env_config = config.get(env, {})
@@ -412,6 +434,8 @@ async def setup_mqtt(app: FastAPI, worker_manager: WorkerManager) -> MQTTBridge
host = mqtt_config.get("host", "localhost") host = mqtt_config.get("host", "localhost")
port = mqtt_config.get("port", 1883) port = mqtt_config.get("port", 1883)
custom_topics = list(mqtt_config.get("custom_topics", []) or [])
custom_topics += list(getattr(app.state, "plugin_mqtt_topics", []) or [])
# 1. 启动 Broker 子进程(文件锁已确保只有一个) # 1. 启动 Broker 子进程(文件锁已确保只有一个)
setup_mqtt_broker(app, host, port) setup_mqtt_broker(app, host, port)
@@ -421,7 +445,12 @@ async def setup_mqtt(app: FastAPI, worker_manager: WorkerManager) -> MQTTBridge
logger.info("MQTT Bridge 已由其他 worker 启动,跳过") logger.info("MQTT Bridge 已由其他 worker 启动,跳过")
return None return None
bridge = MQTTBridge(worker_manager=worker_manager, host=host, port=port) bridge = MQTTBridge(
worker_manager=worker_manager,
host=host,
port=port,
custom_topics=custom_topics,
)
app.state.mqtt_bridge = bridge app.state.mqtt_bridge = bridge
await bridge.start() await bridge.start()
logger.info(f"MQTT Bridge 已启动 ({host}:{port})") logger.info(f"MQTT Bridge 已启动 ({host}:{port})")
+72 -7
View File
@@ -1,5 +1,5 @@
"""MQTT Bridge — 纯客户端模式,Broker 由子进程管理""" """MQTT Bridge — 纯客户端模式,Broker 由子进程管理"""
import asyncio, json import asyncio, json, re
from typing import Any, Optional from typing import Any, Optional
from amqtt.client import MQTTClient from amqtt.client import MQTTClient
from amqtt.mqtt.constants import QOS_1 from amqtt.mqtt.constants import QOS_1
@@ -8,12 +8,42 @@ logger = get_logger("mqtt")
MQTT_TOPIC_PREFIX = "chongming" MQTT_TOPIC_PREFIX = "chongming"
MQTT_SUBSCRIBE_TOPIC = f"{MQTT_TOPIC_PREFIX}/+/+" MQTT_SUBSCRIBE_TOPIC = f"{MQTT_TOPIC_PREFIX}/+/+"
def _compile_topic(topic: str) -> re.Pattern:
"""
将 MQTT 主题模板(支持 + / # 通配符)编译为正则表达式。
- ``+`` 匹配单级(编译为捕获组 ``([^/]+)``)
- ``#`` 匹配多级(编译为捕获组 ``(.*)``,须位于末尾)
捕获组按出现顺序对应,供 {1} {2}... 占位符替换使用。
"""
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 MQTTBridge: class MQTTBridge:
def __init__(self, worker_manager, host="localhost", port=1883): def __init__(self, worker_manager, host="localhost", port=1883, custom_topics=None):
self._wm = worker_manager; self._host = host; self._port = port self._wm = worker_manager; self._host = host; self._port = port
self._client: Optional[MQTTClient] = None self._client: Optional[MQTTClient] = None
self._lt: Optional[asyncio.Task] = None self._lt: Optional[asyncio.Task] = None
self._running = False self._running = False
# custom_topics: [{"topic","worker","subject"?,"response_topic"?}, ...]
self._custom_topics = custom_topics or []
self._compiled = [
(ct, _compile_topic(ct.get("topic", "")))
for ct in self._custom_topics if ct.get("topic")
]
async def start(self): async def start(self):
if self._running: return if self._running: return
@@ -31,8 +61,11 @@ class MQTTBridge:
async def _sc(self): async def _sc(self):
self._client = MQTTClient(config={"auto_reconnect": False}) self._client = MQTTClient(config={"auto_reconnect": False})
await self._client.connect(f"mqtt://{self._host}:{self._port}/") await self._client.connect(f"mqtt://{self._host}:{self._port}/")
await self._client.subscribe([(MQTT_SUBSCRIBE_TOPIC, QOS_1)]) topics = [(MQTT_SUBSCRIBE_TOPIC, QOS_1)]
logger.info(f"已订阅 {MQTT_SUBSCRIBE_TOPIC}") topics += [(ct["topic"], QOS_1) for ct, _ in self._compiled]
await self._client.subscribe(topics)
logger.info(f"已订阅 {MQTT_SUBSCRIBE_TOPIC}"
+ (f" 及 {len(self._compiled)} 个自定义主题" if self._compiled else ""))
self._lt = asyncio.create_task(self._loop()) self._lt = asyncio.create_task(self._loop())
async def _sc_stop(self): async def _sc_stop(self):
@@ -62,14 +95,46 @@ class MQTTBridge:
async def _handle(self, m): async def _handle(self, m):
t: str = m.topic; p = m.data # type: ignore t: str = m.topic; p = m.data # type: ignore
try: pl = json.loads(p.decode("utf-8"))
except json.JSONDecodeError: return
pl.setdefault("_method", "GET")
# 1. 优先匹配显式配置的自定义订阅主题(顺序即优先级)
for ct, compiled in self._compiled:
matched = compiled.match(t)
if matched:
await self._route_custom(ct, matched, pl, t)
return
# 2. 内置接口 chongming/{worker}/{operation}
parts = t.split("/") parts = t.split("/")
if len(parts) < 3: return if len(parts) < 3: return
wt, op = parts[1], parts[2] wt, op = parts[1], parts[2]
subj = f"{wt}.{op}" subj = f"{wt}.{op}"
rt = f"{MQTT_TOPIC_PREFIX}/{wt}/{op}/response" rt = f"{MQTT_TOPIC_PREFIX}/{wt}/{op}/response"
try: pl = json.loads(p.decode("utf-8")) await self._route(wt, subj, pl, rt)
except json.JSONDecodeError: return
pl.setdefault("_method", "GET") def _fill(self, 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
async def _route_custom(self, ct: dict, matched: re.Match, pl: dict, topic: str):
worker = ct.get("worker", "")
# subject 默认 "<worker>.<topic 模板最后一段>",与内置 <插件>.<操作> 约定一致
subject_tpl = ct.get("subject")
if not subject_tpl:
last = ct["topic"].split("/")[-1]
subject_tpl = f"{worker}.{last}" if last not in ("+", "#") else worker
subject = self._fill(subject_tpl, matched)
# response_topic 默认 "<实际匹配主题>/response"
rt_tpl = ct.get("response_topic")
rt = self._fill(rt_tpl, matched) if rt_tpl else f"{topic}/response"
await self._route(worker, subject, pl, rt)
async def _route(self, wt: str, subj: str, pl: dict, rt: str):
logger.info(f"MQTT -> {subj}") logger.info(f"MQTT -> {subj}")
try: try:
r = await self._wm.call(worker_type=wt, subject=subj, payload=pl, r = await self._wm.call(worker_type=wt, subject=subj, payload=pl,
+205
View File
@@ -0,0 +1,205 @@
"""
MQTT Bridge 自定义订阅主题单元测试
验证:
1. _compile_topic 对 + / # 通配符的匹配与捕获
2. 自定义主题路由: 主题匹配 → worker/subject/response_topic 解析 → worker_manager.call 调用
3. 内置 chongming/... 主题兜底逻辑不变
"""
import asyncio
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
sys.path.insert(0, str(Path(__file__).parent / "src"))
from chongming.app.core.mqtt_bridge import (
MQTTBridge,
MQTT_TOPIC_PREFIX,
_compile_topic,
)
def make_msg(topic, payload):
m = MagicMock()
m.topic = topic
m.data = json.dumps(payload).encode("utf-8")
return m
class FakeWM:
def __init__(self):
self.calls = []
self.result = {"result": "ok"}
async def call(self, worker_type, subject, payload, timeout=30, cacheable=False):
self.calls.append(
{
"worker_type": worker_type,
"subject": subject,
"payload": payload,
"timeout": timeout,
"cacheable": cacheable,
}
)
return self.result
def make_bridge(wm, custom_topics):
"""构造一个不真正连接 Broker 的 Bridge,并捕获发布消息"""
bridge = MQTTBridge(worker_manager=wm, custom_topics=custom_topics)
bridge._client = MagicMock()
bridge._running = True
published = []
async def fake_publish(topic, data, qos=None):
published.append((topic, data.decode("utf-8")))
bridge._client.publish = fake_publish
return bridge, published
def test_compile_topic():
# 单级通配符
p = _compile_topic("devices/+/telemetry")
m = p.match("devices/dev-01/telemetry")
assert m and m.groups() == ("dev-01",)
assert not p.match("devices/a/b/telemetry")
# 多级通配符
p = _compile_topic("data/#")
m = p.match("data/a/b/c")
assert m and m.groups() == ("a/b/c",)
# 无通配符
p = _compile_topic("fixed/topic")
assert p.match("fixed/topic")
assert not p.match("fixed/topic/x")
# 前缀必须完整匹配
p = _compile_topic("devices/+/telemetry")
assert not p.match("xdevices/a/telemetry")
print("✅ _compile_topic 通配符编译/匹配通过")
async def test_custom_routing():
wm = FakeWM()
bridge, published = make_bridge(
wm,
[
{"topic": "devices/+/telemetry", "worker": "device"},
{
"topic": "foo/#",
"worker": "foo",
"subject": "foo.handle.{1}",
"response_topic": "out/{1}/resp",
},
],
)
# 1. 默认 subject = <worker>.<最后一段>;响应 = <实际主题>/response
await bridge._handle(make_msg("devices/dev-01/telemetry", {"v": 1}))
assert wm.calls[-1]["worker_type"] == "device"
assert wm.calls[-1]["subject"] == "device.telemetry"
assert published[-1][0] == "devices/dev-01/telemetry/response"
# 2. 带占位符 + 自定义响应主题
await bridge._handle(make_msg("foo/a/b", {"v": 2}))
assert wm.calls[-1]["worker_type"] == "foo"
assert wm.calls[-1]["subject"] == "foo.handle.a/b"
assert published[-1][0] == "out/a/b/resp"
# 3. 内置 chongming 兜底
await bridge._handle(
make_msg(f"{MQTT_TOPIC_PREFIX}/example/add", {"a": 1, "b": 2})
)
assert wm.calls[-1]["worker_type"] == "example"
assert wm.calls[-1]["subject"] == "example.add"
assert published[-1][0] == f"{MQTT_TOPIC_PREFIX}/example/add/response"
print("✅ 自定义主题路由 + 内置兜底通过")
async def test_custom_routing_control_fields():
wm = FakeWM()
bridge, published = make_bridge(
wm, [{"topic": "devices/+/telemetry", "worker": "device"}]
)
await bridge._handle(
make_msg(
"devices/d1/telemetry",
{"_timeout": 5, "_cacheable": True, "_method": "POST"},
)
)
assert wm.calls[-1]["timeout"] == 5.0
assert wm.calls[-1]["cacheable"] is True
assert "_timeout" not in wm.calls[-1]["payload"]
assert wm.calls[-1]["payload"].get("_method") == "POST"
print("✅ 控制字段 (_timeout/_cacheable/_method) 透传通过")
async def test_non_json_dropped():
wm = FakeWM()
bridge, published = make_bridge(
wm, [{"topic": "devices/+/telemetry", "worker": "device"}]
)
bad = MagicMock()
bad.topic = "devices/d1/telemetry"
bad.data = b"not-json"
await bridge._handle(bad)
assert wm.calls == []
assert published == []
print("✅ 非 JSON 负载被丢弃通过")
async def test_board_ping_topic():
"""aibox_sophon_adapter 插件:设备发布到 /board_ping → 路由到 aibox_sophon_adapter.board_ping"""
wm = FakeWM()
bridge, published = make_bridge(
wm,
[
{
"topic": "/board_ping",
"worker": "aibox_sophon_adapter",
"subject": "aibox_sophon_adapter.board_ping",
}
],
)
await bridge._handle(make_msg("/board_ping", {"BoardId": "B001", "Status": 1}))
assert wm.calls[-1]["worker_type"] == "aibox_sophon_adapter"
assert wm.calls[-1]["subject"] == "aibox_sophon_adapter.board_ping"
assert published[-1][0] == "/board_ping/response"
print("✅ /board_ping 自定义主题路由通过")
def test_plugin_config_driven_collection():
"""插件在其 config/workers/<type>/config.toml 中声明 [[workers.mqtt_topics]],
网关 _discover_plugins 自动收集,并默认 worker 为该插件类型。"""
from chongming.app.core.lifespan_setup import _discover_plugins
plugins = _discover_plugins()
by_type = {p["type"]: p for p in plugins}
assert "aibox_sophon_adapter" in by_type, "未发现 aibox_sophon_adapter 插件"
topics = by_type["aibox_sophon_adapter"]["mqtt_topics"]
assert any(
t["topic"] == "/board_ping" and t["worker"] == "aibox_sophon_adapter"
and t["subject"] == "aibox_sophon_adapter.board_ping"
for t in topics
), topics
# 不应泄漏到插件运行时配置
assert "mqtt_topics" not in by_type["aibox_sophon_adapter"]["config"]
print("✅ 插件级 [[workers.mqtt_topics]] 收集通过")
async def main():
test_compile_topic()
await test_custom_routing()
await test_custom_routing_control_fields()
await test_non_json_dropped()
await test_board_ping_topic()
test_plugin_config_driven_collection()
print("\n🎉 全部自定义订阅主题测试通过")
if __name__ == "__main__":
asyncio.run(main())