243 lines
9.4 KiB
Python
243 lines
9.4 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.devices = {} # device_id(client_id) -> {role, parent_id, last_seen}
|
|||
|
|
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):
|
|||
|
|
"""接收大屏心跳:更新设备表在线时间(设备由注册表登记 role/parent)"""
|
|||
|
|
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:
|
|||
|
|
cur = self.devices.get(cid, {"role": "", "parent_id": ""})
|
|||
|
|
cur["last_seen"] = time.time()
|
|||
|
|
self.devices[cid] = cur
|
|||
|
|
self._prune()
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
def _prune(self):
|
|||
|
|
"""剔除超时未上报的设备"""
|
|||
|
|
cutoff = time.time() - settings.SCREEN_TTL
|
|||
|
|
self.devices = {k: v for k, v in self.devices.items() if v.get("last_seen", 0) > cutoff}
|
|||
|
|
|
|||
|
|
# ---------- 状态查询 ----------
|
|||
|
|
def screens_online(self):
|
|||
|
|
with self._lock:
|
|||
|
|
return sum(1 for v in self.devices.values() if v.get("last_seen", 0) > time.time() - settings.SCREEN_TTL)
|
|||
|
|
|
|||
|
|
def status(self):
|
|||
|
|
with self._lock:
|
|||
|
|
self._prune()
|
|||
|
|
devices = [
|
|||
|
|
{
|
|||
|
|
"device_id": cid,
|
|||
|
|
"role": v.get("role", ""),
|
|||
|
|
"parent_id": v.get("parent_id", ""),
|
|||
|
|
}
|
|||
|
|
for cid, v in self.devices.items()
|
|||
|
|
]
|
|||
|
|
return {
|
|||
|
|
"mqtt_connected": self.connected,
|
|||
|
|
"mqtt_host": f"{settings.MQTT_HOST}:{settings.MQTT_PORT}",
|
|||
|
|
"screens_online": len(devices),
|
|||
|
|
"devices": devices,
|
|||
|
|
"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 register_device(self, device_id, role="", parent_id=""):
|
|||
|
|
"""前端上报设备:唯一 device_id + 角色(main/secondary) + 从属主屏 parent_id。"""
|
|||
|
|
device_id = (device_id or "").strip()
|
|||
|
|
if not device_id:
|
|||
|
|
return
|
|||
|
|
with self._lock:
|
|||
|
|
cur = self.devices.get(device_id, {})
|
|||
|
|
if role:
|
|||
|
|
cur["role"] = role
|
|||
|
|
if parent_id:
|
|||
|
|
cur["parent_id"] = parent_id
|
|||
|
|
cur["last_seen"] = time.time()
|
|||
|
|
self.devices[device_id] = cur
|
|||
|
|
self._prune()
|
|||
|
|
log.info("mqtt: 设备注册 id=%s role=%s parent=%s", device_id, role or cur.get("role"), parent_id or cur.get("parent_id"))
|
|||
|
|
|
|||
|
|
def publish_command(self, action, params=None, screen_id=None, screen_role=None):
|
|||
|
|
"""按客户端(设备)路由下发指令:
|
|||
|
|
· screen_id 精确到某台设备 → 只发到该设备独有 topic
|
|||
|
|
· screen_role(main/secondary) → 解析到该角色的设备,发到其独有 topic
|
|||
|
|
· 两者皆空(全部)→ 遍历在线设备列表,依次下发到各自独有 topic
|
|||
|
|
客户端只订阅自己的独有频道,因此定向指令绝不会被其它屏幕收到。"""
|
|||
|
|
payload = {
|
|||
|
|
"cmd_id": new_cmd_id(),
|
|||
|
|
"ts": int(time.time() * 1000),
|
|||
|
|
"action": action,
|
|||
|
|
"params": params or {},
|
|||
|
|
}
|
|||
|
|
target = None
|
|||
|
|
requested = bool(screen_id or screen_role in ("main", "secondary"))
|
|||
|
|
if screen_id:
|
|||
|
|
target = (screen_id or "").strip()
|
|||
|
|
elif screen_role in ("main", "secondary"):
|
|||
|
|
with self._lock:
|
|||
|
|
for cid, v in self.devices.items():
|
|||
|
|
if v.get("role") == screen_role:
|
|||
|
|
target = cid
|
|||
|
|
break
|
|||
|
|
if target:
|
|||
|
|
# 定向:只发到目标设备独有频道
|
|||
|
|
return self._publish_to(payload, target, screen_role)
|
|||
|
|
if requested:
|
|||
|
|
# 指定了目标但未找到对应设备:不广播,避免误发到全部
|
|||
|
|
log.warning("mqtt: 目标设备未找到(id=%s role=%s),未下发", screen_id, screen_role)
|
|||
|
|
payload["published"] = False
|
|||
|
|
with self._lock:
|
|||
|
|
self.last_command = {"action": action, "params": params or {}, "ts": payload["ts"], "published": False, "topic": "none"}
|
|||
|
|
return payload
|
|||
|
|
|
|||
|
|
# 全部:遍历在线设备列表,依次下发到各自独有频道
|
|||
|
|
with self._lock:
|
|||
|
|
cutoff = time.time() - settings.SCREEN_TTL
|
|||
|
|
online = [cid for cid, v in self.devices.items() if v.get("last_seen", 0) > cutoff]
|
|||
|
|
ok = True
|
|||
|
|
published_topics = []
|
|||
|
|
for cid in online:
|
|||
|
|
p = dict(payload)
|
|||
|
|
p["screen_id"] = cid
|
|||
|
|
with self._lock:
|
|||
|
|
p["screen_role"] = self.devices.get(cid, {}).get("role", "")
|
|||
|
|
if self.publish(f"{settings.TOPIC_COMMAND}/{cid}", p):
|
|||
|
|
bus.emit(p)
|
|||
|
|
published_topics.append(cid)
|
|||
|
|
else:
|
|||
|
|
ok = False
|
|||
|
|
payload["published"] = ok
|
|||
|
|
payload["screen_id"] = ""
|
|||
|
|
payload["screen_role"] = "both"
|
|||
|
|
with self._lock:
|
|||
|
|
self.last_command = {
|
|||
|
|
"action": action,
|
|||
|
|
"params": params or {},
|
|||
|
|
"ts": payload["ts"],
|
|||
|
|
"published": ok,
|
|||
|
|
"topic": "all",
|
|||
|
|
}
|
|||
|
|
log.info("mqtt: 全部下发 %s 到 %d 台设备", action, len(published_topics))
|
|||
|
|
return payload
|
|||
|
|
|
|||
|
|
def _publish_to(self, payload, target, screen_role=""):
|
|||
|
|
"""发到指定设备独有频道,并记录 last_command。"""
|
|||
|
|
topic = f"{settings.TOPIC_COMMAND}/{target}"
|
|||
|
|
payload["screen_id"] = target
|
|||
|
|
with self._lock:
|
|||
|
|
payload["screen_role"] = self.devices.get(target, {}).get("role", screen_role or "")
|
|||
|
|
ok = self.publish(topic, payload)
|
|||
|
|
payload["published"] = ok
|
|||
|
|
with self._lock:
|
|||
|
|
self.last_command = {
|
|||
|
|
"action": payload["action"],
|
|||
|
|
"params": payload.get("params") or {},
|
|||
|
|
"ts": payload["ts"],
|
|||
|
|
"published": ok,
|
|||
|
|
"topic": topic,
|
|||
|
|
}
|
|||
|
|
if ok:
|
|||
|
|
bus.emit(payload)
|
|||
|
|
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()
|