"""预警发布器 (MVP-2 / D18) 将统一 ``AlertEvent`` 格式化为前端/外部系统可消费的 JSON 消息, 并通过 ``MQTTService`` 发布到对应主题。 主题命名规则:: {prefix}/{event_type}/{source_id} # 单流单类型 {prefix}/all # 全量订阅 {prefix}/{event_type} # 按事件类型订阅 消息格式 (与前端约定):: { "alert_id": "...", "event_type": "fire", "severity": "critical", "confidence": 0.92, "source_id": "cam-01", "rule_name": "fire_critical", "first_seen": 1781233417.12, "last_seen": 1781233418.45, "occurrence_count": 3, "detections": [{...}], "metadata": {...} } """ from __future__ import annotations import logging import time from typing import Any, Dict, Iterable, List, Optional from models.event_schemas import AlertEvent from .mqtt_service import MQTTService logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # AlertPublisher # --------------------------------------------------------------------------- class AlertPublisher: """预警事件发布器。 Args: mqtt_service: MQTT 服务实例 topic_prefix: 主题前缀 broadcast_all: 是否同时发布到 ``{prefix}/all`` 总订阅主题 qos: 发布 QoS 等级 retain: 是否保留最后一条预警 (用于新订阅者立即获取最近状态) """ def __init__( self, mqtt_service: MQTTService, topic_prefix: str = "video/alerts", broadcast_all: bool = True, qos: int = 1, retain: bool = False, ) -> None: self._mqtt = mqtt_service self.topic_prefix = topic_prefix.rstrip("/") self.broadcast_all = broadcast_all self.qos = qos self.retain = retain self._published_count = 0 self._failed_count = 0 # ------------------------------------------------------------------ # 发布 # ------------------------------------------------------------------ async def publish_alert(self, alert: AlertEvent) -> bool: """发布单条预警事件。 Returns: True 表示至少有一条主题发布成功 """ payload = self.format_alert(alert) topics = self._build_topics(alert) any_success = False for topic in topics: ok = await self._mqtt.publish( topic, payload, qos=self.qos, retain=self.retain ) if ok: any_success = True self._published_count += 1 logger.debug("AlertPublisher 已发布 topic=%s alert_id=%s", topic, alert.alert_id) else: self._failed_count += 1 logger.warning( "AlertPublisher 发布失败 topic=%s alert_id=%s", topic, alert.alert_id ) return any_success async def publish_alerts(self, alerts: Iterable[AlertEvent]) -> int: """批量发布预警事件,返回成功条数。""" success = 0 for alert in alerts: if await self.publish_alert(alert): success += 1 return success # ------------------------------------------------------------------ # 格式化 # ------------------------------------------------------------------ @staticmethod def format_alert(alert: AlertEvent) -> Dict[str, Any]: """将 AlertEvent 格式化为 dict (JSON 友好)。""" return { "alert_id": alert.alert_id, "event_type": alert.event_type.value, "severity": alert.severity.value, "confidence": round(alert.confidence, 4), "source_id": alert.source_id, "rule_name": alert.rule_name, "first_seen": alert.first_seen, "last_seen": alert.last_seen, "occurrence_count": alert.occurrence_count, "detections": [ { "detection_id": d.detection_id, "track_id": d.track_id, "class_name": d.class_name, "label": d.label, "confidence": round(d.confidence, 4), "bbox": d.bbox.to_list(), "source": d.source.value, "model_id": d.model_id, "timestamp": d.timestamp, } for d in alert.detections ], "metadata": alert.metadata, "published_at": time.time(), } # ------------------------------------------------------------------ # 主题构建 # ------------------------------------------------------------------ def _build_topics(self, alert: AlertEvent) -> List[str]: topics: List[str] = [] event_type = alert.event_type.value source_id = alert.source_id or "unknown" # 细粒度主题 topics.append(f"{self.topic_prefix}/{event_type}/{source_id}") # 事件类型订阅 topics.append(f"{self.topic_prefix}/{event_type}") # 总广播 if self.broadcast_all: topics.append(f"{self.topic_prefix}/all") return topics # ------------------------------------------------------------------ # 状态 # ------------------------------------------------------------------ @property def stats(self) -> Dict[str, Any]: return { "topic_prefix": self.topic_prefix, "published_count": self._published_count, "failed_count": self._failed_count, "mqtt_connected": self._mqtt.is_connected, } __all__ = ["AlertPublisher"]