feat: 实现设备注册和精准控制功能,支持双屏管理;更新相关逻辑以优化设备状态跟踪

This commit is contained in:
Pine
2026-08-19 14:19:37 +08:00
parent 93b27cf227
commit e72442dae0
15 changed files with 264 additions and 53 deletions
+106 -14
View File
@@ -33,7 +33,7 @@ class MqttHub:
self.client = None
self.connected = False
self._lock = threading.RLock()
self._screens = {} # client_id -> last_seen_ts
self.devices = {} # device_id(client_id) -> {role, parent_id, last_seen}
self.last_command = None # {action, params, ts, published}
# ---------- 生命周期 ----------
@@ -81,30 +81,45 @@ class MqttHub:
log.warning("MQTT 断开(rc=%s),自动重连中...", rc)
def _on_message(self, client, userdata, msg):
"""接收大屏心跳:记录 client_id 与时间"""
"""接收大屏心跳:更新设备表在线时间(设备由注册表登记 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:
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}
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:
cutoff = time.time() - settings.SCREEN_TTL
return sum(1 for v in self._screens.values() if v > cutoff)
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": self.screens_online(),
"screens_online": len(devices),
"devices": devices,
"last_command": self.last_command,
}
@@ -120,26 +135,103 @@ class MqttHub:
log.warning("MQTT 发布失败: %s", e)
return False
def publish_command(self, action, params=None):
"""统一指令信封:{cmd_id, ts, action, params, published}
MQTT 广播 + SSE 兼容推送;published 标记是否真正发布成功"""
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 {},
}
ok = self.publish(settings.TOPIC_COMMAND, payload)
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) # SSE 兼容通道(仅发布成功时推送)
bus.emit(payload)
return payload
def publish_tick(self, snapshot):
+18 -1
View File
@@ -73,6 +73,8 @@ class StateBody(BaseModel):
class DisplayCommandBody(BaseModel):
action: str
params: dict = Field(default_factory=dict)
screen_id: str = "" # 目标屏幕唯一 client_id(精确到某台)
screen_role: str = "" # main | secondary(按角色定位)
class ChatMessage(BaseModel):
@@ -487,13 +489,28 @@ _VALID_DISPLAY_ACTIONS = {
}
class RegisterBody(BaseModel):
device_id: str = "" # 唯一设备标识(MQTT client_id
role: str = "" # main | secondary
parent_id: str = "" # 副屏从属的主屏 device_id;主屏为空
@router.post("/api/display/register")
async def display_register(body: RegisterBody):
"""设备注册:前端上报唯一 device_id + 角色 + 从属主屏,后端维护设备表供精准控制。"""
hub.register_device(body.device_id, body.role, body.parent_id)
return {"ok": True, "device_id": body.device_id, "role": body.role, "parent_id": body.parent_id}
@router.post("/api/display/command")
async def display_command_publish(body: DisplayCommandBody):
"""管理端/任意客户端通过 REST 发控制指令 → 后端转 MQTT 广播给所有大屏"""
if body.action not in _VALID_DISPLAY_ACTIONS:
return JSONResponse({"ok": False, "error": f"不支持的指令: {body.action}"}, status_code=400)
cmd = hub.publish_command(body.action, body.params)
# 按屏幕终端路由:screen_id 精确到某客户端;screen_role 按主/副定位;皆空 → 全局广播
cmd = hub.publish_command(body.action, body.params, body.screen_id or None, body.screen_role or None)
return {"ok": cmd.get("published", False), "cmd_id": cmd["cmd_id"], "action": body.action,
"screen_id": body.screen_id or "", "screen_role": body.screen_role or "both",
"mqtt_connected": hub.connected}
+54 -15
View File
@@ -10,8 +10,46 @@
var fullscreen = !!init.fullscreen;
var autostart = !!init.autostart;
/* 目标屏(双屏独立控制):both | main | secondary */
var screenTarget = 'both';
/* 目标屏幕(按 MQTT 客户端精准控制):client_id + role */
var screenId = '';
var screenRole = '';
function applyScreenTarget(sel) {
sel = sel || document.getElementById('screenTarget');
if (!sel) return;
var v = sel.value || '';
screenId = v;
var opt = null;
if (v) { for (var i = 0; i < sel.options.length; i++) { if (sel.options[i].value === v) { opt = sel.options[i]; break; } } }
screenRole = opt ? (opt.getAttribute('data-role') || '') : '';
}
function loadScreens() {
fetch('/api/display/state', { cache: 'no-store' })
.then(function (r) { return r.json(); })
.then(function (d) {
var sel = document.getElementById('screenTarget');
var cnt = document.getElementById('screenCount');
if (!sel) return;
var devices = (d && d.devices) || [];
if (cnt) cnt.textContent = '在线设备:' + (devices.length || 0) + ' 台';
var cur = sel.value;
sel.innerHTML = '<option value="">全部屏幕</option>';
devices.forEach(function (dev) {
var role = dev.role || '';
var name = role === 'main' ? '主屏' : role === 'secondary' ? '副屏' : (role || '屏幕');
var parent = (dev.role === 'secondary' && dev.parent_id) ? '·属于:' + String(dev.parent_id).slice(0, 8) : '';
var opt = document.createElement('option');
opt.value = dev.device_id;
opt.setAttribute('data-role', role);
opt.textContent = name + parent + ' · ' + String(dev.device_id).slice(0, 16);
sel.appendChild(opt);
});
if (cur) sel.value = cur;
applyScreenTarget(sel);
})
.catch(function () {});
}
/* ---------- Toast ---------- */
var toastContainer = document.getElementById('toastContainer');
@@ -82,6 +120,7 @@
setConn('api', true, '在线');
setConn('mqtt', !!d.mqtt_connected, d.mqtt_connected ? '已连接(' + (d.mqtt_host || '') + '' : '未连接');
setConn('screens', (d.screens_online || 0) > 0, (d.screens_online || 0) + ' 台');
loadScreens(); // 刷新目标屏幕下拉(按 MQTT 客户端)
var lc = d.last_command;
if (lc) {
var names = { navigate: '页面切换', play: '播放', pause: '暂停', next: '下一项', prev: '上一项',
@@ -98,6 +137,12 @@
});
}
if (connEls.api) { pollStatus(); setInterval(pollStatus, 3000); }
// 目标屏幕下拉:变更时记录目标 client_id + role;初始加载一次
var screenSel = document.getElementById('screenTarget');
if (screenSel) {
screenSel.addEventListener('change', function () { applyScreenTarget(screenSel); });
loadScreens();
}
/* =========================================================
事件委托:页面切换 / 播放控制 / 媒体操作
@@ -110,19 +155,10 @@
});
document.addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('[data-nav], [data-mqtt], [data-control], [data-action], .copy-btn, [data-preview], .screen-target, #mqttAiSend, #mqttAlertSend') : null;
var btn = e.target.closest ? e.target.closest('[data-nav], [data-mqtt], [data-control], [data-action], .copy-btn, [data-preview], #mqttAiSend, #mqttAlertSend') : null;
if (!btn) return;
e.stopPropagation();
// 目标屏选择(双屏独立控制)
if (btn.classList.contains('screen-target')) {
screenTarget = btn.getAttribute('data-screen') || 'both';
var btns = document.querySelectorAll('.screen-target');
for (var i = 0; i < btns.length; i++) btns[i].classList.toggle('active', btns[i] === btn);
toast('目标屏:' + ({ both: '全部(镜像)', main: '仅主屏', secondary: '仅副屏' }[screenTarget] || screenTarget));
return;
}
// 预览
if (btn.hasAttribute('data-preview')) { openPreview(btn.closest('.media-card')); return; }
@@ -141,10 +177,13 @@
if (btn.hasAttribute('data-mqtt')) {
var cmd = null;
try { cmd = JSON.parse(btn.getAttribute('data-mqtt')); } catch (err) { toast('指令 JSON 错误', 'error'); return; }
// 双屏独立控制:除「双屏开关」外,注入目标屏(main/secondary/both
if (cmd.action !== 'dual_screen') cmd.screen = screenTarget;
// 屏幕终端精准控制:除「双屏开关」外,注入目标 client_id + role;全空=全局广播
if (cmd.action !== 'dual_screen') { cmd.screen_id = screenId; cmd.screen_role = screenRole; }
var tgtTxt = screenId
? (cmd.screen_role === 'main' ? '主屏' : cmd.screen_role === 'secondary' ? '副屏' : '屏幕') + '·' + String(screenId).slice(0, 10)
: '全部';
post('/api/display/command', cmd, function (d) {
toast(d && d.ok !== false ? '指令已发送:' + (cmd.action || '') : '发送失败(MQTT 未连接?)',
toast(d && d.ok !== false ? ('指令已发送:' + (cmd.action || '') + ' → ' + tgtTxt) : '发送失败(MQTT 未连接?)',
d && d.ok !== false ? 'ok' : 'error');
});
return;
+7 -6
View File
@@ -108,14 +108,15 @@
<div class="card">
<div class="card-header">
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="8" rx="1.5"/><rect x="3" y="13" width="8" height="8" rx="1.5"/><rect x="13" y="13" width="8" height="8" rx="1.5"/></svg></div>
<h3>目标屏(双屏独立控制)</h3>
<h3>目标屏幕(按 MQTT 客户端精准控制)</h3>
</div>
<div class="page-switch-grid">
<button class="page-switch-btn screen-target" data-screen="both">全部(镜像)</button>
<button class="page-switch-btn screen-target" data-screen="main">仅主屏</button>
<button class="page-switch-btn screen-target" data-screen="secondary">仅副屏</button>
<div class="page-switch-grid" style="align-items:center;gap:8px;">
<span id="screenCount" class="mqtt-hint" style="white-space:nowrap;">在线屏幕:加载中…</span>
<select id="screenTarget" style="flex:1;min-width:0;padding:8px 10px;border:1px solid rgba(0,189,125,.2);border-radius:8px;background:#0e1a2b;color:#e8f0fa;font-size:12px;">
<option value="">全部屏幕</option>
</select>
</div>
<div class="mqtt-hint">选择后,下方命令只发送到对应屏幕;「全部」两边同步。</div>
<div class="mqtt-hint">选择某台屏幕后,下方命令只发给它(按 client_id);「全部」则所有屏幕同步。</div>
</div>
<div class="card">