07dbd404a9
播放控制区:
- 从底部中央移到右下角,整体缩小(播放键 42→30px、导航键 34→26px)
- 背景更透(0.8→0.55)、阴影更轻,视觉弱化不抢内容
- 播放名不再显示完整地址:URL 媒体只显示文件名(cleanName 清洗,去掉协议/路径/查询)
MQTT 客户端 ID:
- 后端 client_id 由固定 dpm-backend 改为 dpm-backend-{pid}-{随机},多实例不互踢
- 前端 client_id 增加时间戳 + 更高熵随机,避免多屏/多标签竞争
151 lines
5.3 KiB
Python
151 lines
5.3 KiB
Python
# -*- 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()
|