# -*- coding: utf-8 -*- """MQTT 发布/订阅中心 —— 后端 ↔ 前端控制通道 发布:opc/display/command(页面/媒体/卡片控制)、opc/dashboard/tick(数据快照) 订阅:opc/display/heartbeat(大屏在线心跳,统计在线大屏数) """ import json import logging import os import threading import time import uuid import paho.mqtt.client as mqtt from .config import settings from .event_bus import bus log = logging.getLogger("dpm.mqtt") def new_cmd_id(): return uuid.uuid4().hex[:12] def _unique_client_id(base): """每个客户端进程使用唯一 client_id,避免与其它实例竞争被 broker 踢下线""" return f"{base}-{os.getpid()}-{uuid.uuid4().hex[:6]}" class MqttHub: def __init__(self): self.client = None self.connected = False self._lock = threading.RLock() self._screens = {} # client_id -> last_seen_ts self.last_command = None # {action, params, ts, published} # ---------- 生命周期 ---------- def start(self): if not settings.MQTT_ENABLED: log.info("MQTT 已禁用(DPM_MQTT_ENABLED=0)") return try: self.client = mqtt.Client( client_id=_unique_client_id(settings.MQTT_CLIENT_ID), clean_session=True, protocol=mqtt.MQTTv311, ) if settings.MQTT_USERNAME: self.client.username_pw_set(settings.MQTT_USERNAME, settings.MQTT_PASSWORD) self.client.on_connect = self._on_connect self.client.on_disconnect = self._on_disconnect self.client.on_message = self._on_message self.client.connect_async(settings.MQTT_HOST, settings.MQTT_PORT, keepalive=30) self.client.loop_start() log.info("MQTT 连接中 %s:%s ...", settings.MQTT_HOST, settings.MQTT_PORT) except Exception as e: # noqa: BLE001 log.warning("MQTT 初始化失败(数据 API 不受影响): %s", e) def stop(self): if self.client: try: self.client.loop_stop() self.client.disconnect() except Exception: # noqa: BLE001 pass self.client = None def _on_connect(self, client, userdata, flags, rc): if rc == 0: self.connected = True log.info("MQTT 已连接 %s:%s", settings.MQTT_HOST, settings.MQTT_PORT) client.subscribe(settings.TOPIC_HEARTBEAT, qos=0) else: log.warning("MQTT 连接失败 rc=%s", rc) def _on_disconnect(self, client, userdata, rc): self.connected = False if rc != 0: log.warning("MQTT 断开(rc=%s),自动重连中...", rc) def _on_message(self, client, userdata, msg): """接收大屏心跳:记录 client_id 与时间""" if msg.topic == settings.TOPIC_HEARTBEAT: try: payload = json.loads(msg.payload.decode("utf-8")) cid = payload.get("client_id") or msg.topic with self._lock: self._screens[cid] = time.time() # 清理过期 cutoff = time.time() - settings.SCREEN_TTL self._screens = {k: v for k, v in self._screens.items() if v > cutoff} except Exception: # noqa: BLE001 pass # ---------- 状态查询 ---------- def screens_online(self): with self._lock: cutoff = time.time() - settings.SCREEN_TTL return sum(1 for v in self._screens.values() if v > cutoff) def status(self): return { "mqtt_connected": self.connected, "mqtt_host": f"{settings.MQTT_HOST}:{settings.MQTT_PORT}", "screens_online": self.screens_online(), "last_command": self.last_command, } # ---------- 发布 ---------- def publish(self, topic, payload, qos=1, retain=False): if not self.client or not self.connected: log.debug("MQTT 未连接,丢弃发布 %s", topic) return False try: info = self.client.publish(topic, json.dumps(payload, ensure_ascii=False), qos=qos, retain=retain) return info.rc == mqtt.MQTT_ERR_SUCCESS except Exception as e: # noqa: BLE001 log.warning("MQTT 发布失败: %s", e) return False def publish_command(self, action, params=None): """统一指令信封:{cmd_id, ts, action, params, published} MQTT 广播 + SSE 兼容推送;published 标记是否真正发布成功""" payload = { "cmd_id": new_cmd_id(), "ts": int(time.time() * 1000), "action": action, "params": params or {}, } ok = self.publish(settings.TOPIC_COMMAND, payload) payload["published"] = ok with self._lock: self.last_command = { "action": action, "params": params or {}, "ts": payload["ts"], "published": ok, } if ok: bus.emit(payload) # SSE 兼容通道(仅发布成功时推送) return payload def publish_tick(self, snapshot): payload = {"ts": int(time.time() * 1000), "snapshot": snapshot} self.publish(settings.TOPIC_TICK, payload, qos=0) hub = MqttHub()