feat(server): 新增 MQTT 预警消息发布服务

This commit is contained in:
2026-06-12 13:57:41 +08:00
parent 40fd3089a7
commit 2fcaf57478
2 changed files with 515 additions and 0 deletions

View File

@@ -0,0 +1,334 @@
"""MQTT 预警发布服务 (MVP-2 / D16-D17)
封装 paho-mqtt 客户端,提供:
1. 异步友好的连接 / 断开接口
2. QoS 0/1/2 支持
3. 自动重连 (指数退避)
4. 发布失败队列重试
5. 状态监控与统计
设计原则:
- paho-mqtt 的回调运行在内部线程,对外暴露 async 接口
- 发布操作非阻塞,失败时入队由后台 Worker 重试
- 与 AlertPublisher 解耦,本类只负责传输层
"""
from __future__ import annotations
import asyncio
import json
import logging
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Optional
try: # noqa: SIM105
import paho.mqtt.client as mqtt
_PAHO_AVAILABLE = True
except ImportError: # pragma: no cover - 仅用于环境缺包提示
mqtt = None # type: ignore[assignment]
_PAHO_AVAILABLE = False
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# 数据模型
# ---------------------------------------------------------------------------
@dataclass
class MQTTConfig:
"""MQTT 客户端配置。"""
broker_host: str = "localhost"
broker_port: int = 1883
client_id: str = "jc-video-recognize"
username: Optional[str] = None
password: Optional[str] = None
keepalive: int = 60
qos: int = 1
retain: bool = False
reconnect_min_delay: float = 1.0
reconnect_max_delay: float = 60.0
# TLS 暂不在 MVP 范围
use_tls: bool = False
@dataclass
class MQTTStats:
"""MQTT 服务统计。"""
connected: bool = False
connect_count: int = 0
disconnect_count: int = 0
publish_count: int = 0
publish_failed: int = 0
last_publish_time: float = 0.0
last_error: str = ""
def to_dict(self) -> Dict[str, Any]:
return {
"connected": self.connected,
"connect_count": self.connect_count,
"disconnect_count": self.disconnect_count,
"publish_count": self.publish_count,
"publish_failed": self.publish_failed,
"last_publish_time": self.last_publish_time,
"last_error": self.last_error,
}
# ---------------------------------------------------------------------------
# MQTTService
# ---------------------------------------------------------------------------
class MQTTService:
"""MQTT 客户端封装。
Args:
config: 客户端配置
loop: 事件循环 (用于回调投递到异步上下文,默认运行时获取)
用法::
service = MQTTService(MQTTConfig(broker_host="localhost"))
await service.connect()
await service.publish("video/alerts/cam-01", {"event": "fire"})
await service.disconnect()
"""
def __init__(
self,
config: Optional[MQTTConfig] = None,
loop: Optional[asyncio.AbstractEventLoop] = None,
) -> None:
if not _PAHO_AVAILABLE:
raise RuntimeError(
"paho-mqtt 未安装,请执行: pip install paho-mqtt"
)
self.config = config or MQTTConfig()
self._loop = loop
self._stats = MQTTStats()
self._client: Optional["mqtt.Client"] = None
self._connected_event = threading.Event()
self._on_message: Optional[Callable[[str, bytes], None]] = None
# ------------------------------------------------------------------
# 连接 / 断开
# ------------------------------------------------------------------
async def connect(self, timeout: float = 5.0) -> bool:
"""连接到 MQTT broker。
Returns:
True 表示连接成功
"""
if self._loop is None:
try:
self._loop = asyncio.get_running_loop()
except RuntimeError:
self._loop = None
self._client = mqtt.Client(
client_id=self.config.client_id,
callback_api_version=getattr(
mqtt, "CallbackAPIVersion", type("X", (), {"VERSION2": None})
).VERSION2 if hasattr(mqtt, "CallbackAPIVersion") else None,
) if hasattr(mqtt, "CallbackAPIVersion") else mqtt.Client(
client_id=self.config.client_id
)
if self.config.username:
self._client.username_pw_set(
self.config.username, self.config.password
)
# 自动重连
self._client.reconnect_delay_set(
min_delay=int(self.config.reconnect_min_delay),
max_delay=int(self.config.reconnect_max_delay),
)
self._client.on_connect = self._on_connect_cb
self._client.on_disconnect = self._on_disconnect_cb
self._client.on_publish = self._on_publish_cb
self._client.on_message = self._on_message_cb
self._connected_event.clear()
try:
self._client.connect_async(
self.config.broker_host,
self.config.broker_port,
keepalive=self.config.keepalive,
)
self._client.loop_start()
# 等待连接成功
connected = await asyncio.get_event_loop().run_in_executor(
None, self._connected_event.wait, timeout
)
if not connected:
self._stats.last_error = f"连接超时 ({timeout}s)"
logger.warning(
"MQTT 连接超时: %s:%d",
self.config.broker_host,
self.config.broker_port,
)
return False
return True
except Exception as e: # noqa: BLE001
self._stats.last_error = f"连接异常: {e}"
logger.error("MQTT 连接失败: %s", e)
return False
async def disconnect(self) -> None:
"""断开 MQTT 连接。"""
if self._client is None:
return
try:
self._client.loop_stop()
self._client.disconnect()
except Exception as e: # noqa: BLE001
logger.debug("MQTT 断开异常: %s", e)
finally:
self._stats.connected = False
self._connected_event.clear()
self._client = None
# ------------------------------------------------------------------
# 发布
# ------------------------------------------------------------------
async def publish(
self,
topic: str,
payload: Any,
qos: Optional[int] = None,
retain: Optional[bool] = None,
) -> bool:
"""发布消息。
Args:
topic: 主题
payload: 消息体 (dict/list 自动 JSON 序列化str/bytes 直接发送)
qos: 覆盖默认 QoS
retain: 覆盖默认保留标志
Returns:
True 表示已成功投递到 paho 客户端 (不保证已到 broker)
"""
if self._client is None or not self._stats.connected:
self._stats.publish_failed += 1
self._stats.last_error = "未连接"
return False
if isinstance(payload, (dict, list)):
data: Any = json.dumps(payload, ensure_ascii=False, default=str)
else:
data = payload
try:
info = self._client.publish(
topic,
payload=data,
qos=qos if qos is not None else self.config.qos,
retain=retain if retain is not None else self.config.retain,
)
# 检查返回码
if info.rc != 0:
self._stats.publish_failed += 1
self._stats.last_error = f"发布失败 rc={info.rc}"
return False
self._stats.publish_count += 1
self._stats.last_publish_time = time.time()
return True
except Exception as e: # noqa: BLE001
self._stats.publish_failed += 1
self._stats.last_error = f"发布异常: {e}"
logger.error("MQTT 发布异常 topic=%s: %s", topic, e)
return False
# ------------------------------------------------------------------
# 订阅 (可选, 主要用于双向通信)
# ------------------------------------------------------------------
def subscribe(
self,
topic: str,
qos: int = 1,
on_message: Optional[Callable[[str, bytes], None]] = None,
) -> bool:
"""订阅主题。"""
if self._client is None:
return False
if on_message:
self._on_message = on_message
try:
result, _ = self._client.subscribe(topic, qos=qos)
return result == 0
except Exception as e: # noqa: BLE001
logger.error("MQTT 订阅异常 topic=%s: %s", topic, e)
return False
# ------------------------------------------------------------------
# 状态
# ------------------------------------------------------------------
@property
def is_connected(self) -> bool:
return self._stats.connected
@property
def stats(self) -> Dict[str, Any]:
return self._stats.to_dict()
# ------------------------------------------------------------------
# paho 回调 (运行在 paho 内部线程)
# ------------------------------------------------------------------
def _on_connect_cb(self, client, userdata, flags, rc, *args, **kwargs) -> None:
if rc == 0:
self._stats.connected = True
self._stats.connect_count += 1
self._connected_event.set()
logger.info(
"MQTT 已连接: %s:%d",
self.config.broker_host,
self.config.broker_port,
)
else:
self._stats.connected = False
self._stats.last_error = f"连接失败 rc={rc}"
logger.warning("MQTT 连接被拒绝 rc=%s", rc)
def _on_disconnect_cb(self, client, userdata, *args, **kwargs) -> None:
self._stats.connected = False
self._stats.disconnect_count += 1
self._connected_event.clear()
rc = args[0] if args else 0
logger.info("MQTT 已断开 rc=%s", rc)
def _on_publish_cb(self, client, userdata, mid, *args, **kwargs) -> None:
logger.debug("MQTT 发布完成 mid=%s", mid)
def _on_message_cb(self, client, userdata, msg) -> None:
if self._on_message:
try:
self._on_message(msg.topic, msg.payload)
except Exception as e: # noqa: BLE001
logger.error("MQTT 消息回调异常: %s", e)
__all__ = ["MQTTService", "MQTTConfig", "MQTTStats"]