feat(link): DeviceLink 外壳单例统一 ESP32/ECAT/BLE 健康态
各页只共用 IP、各自 fetch 导致同屏「已连接/连接失败」分裂。 外壳 Owner 轮询 /system/status 与 EtherCAT 状态,BroadcastChannel 广播快照;连接灯只认共享层,SPI 失败单独文案不覆盖设备离线。
This commit is contained in:
@@ -33,6 +33,7 @@ import {
|
||||
import { setBleStage, setBleStageError, setBleStageText, setBleStageStatus, resetBleStages } from './stages.js';
|
||||
import { setProgress, updateProgressFromAck } from './transfer.js';
|
||||
import { showBleVerifyInfo } from './verify.js';
|
||||
import { deviceLink } from '../common/device-link.js';
|
||||
import {
|
||||
clearOtaResultWaiter,
|
||||
resolveOtaResult,
|
||||
@@ -104,6 +105,7 @@ export async function connectBle() {
|
||||
|
||||
setBleStage(2, '已就绪', null);
|
||||
setConnectionState(true);
|
||||
try { deviceLink.reportBle(true, device.name || ''); } catch (_) {}
|
||||
bleLog('BLE', 'GATT 服务与特征获取完成');
|
||||
await enableNotifications();
|
||||
// ★ enableNotifications 内部已包含重试 + 主动断开逻辑;
|
||||
@@ -129,6 +131,7 @@ export async function connectBle() {
|
||||
export function onDisconnected() {
|
||||
bleLog('BLE', '设备已断开', 'W');
|
||||
var wasExpected = state.expectedReboot;
|
||||
try { deviceLink.reportBle(false, ''); } catch (_) {}
|
||||
resetUiAfterDisconnect();
|
||||
if (wasExpected && state.device) {
|
||||
// 是主动重启引起的断开,启动自动重连
|
||||
@@ -151,6 +154,7 @@ export async function disconnectBle() {
|
||||
if (state.device && state.device.gatt && state.device.gatt.connected) {
|
||||
state.device.gatt.disconnect();
|
||||
}
|
||||
try { deviceLink.reportBle(false, ''); } catch (_) {}
|
||||
resetUiAfterDisconnect();
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { showToast } from './toast.js';
|
||||
import {
|
||||
appendHeapLowRecords, loadHeapLowHistory, clearHeapLowHistory, HEAP_HISTORY_MAX
|
||||
} from './heap-low-history.js';
|
||||
import { deviceLink } from './device-link.js';
|
||||
|
||||
// ==================== 渲染缓存(性能) ====================
|
||||
// /system/status 每 1s(中继期 400ms)刷一次 UI。此前每轮全量 innerHTML 重建
|
||||
@@ -55,6 +56,15 @@ function setWidthIfChanged(el, widthStr) {
|
||||
|
||||
// ==================== 连接管理 ====================
|
||||
export function updateConnectionUi(connected, text) {
|
||||
/* DeviceLink 统一层:外壳 Owner 广播的新鲜快照优先于本页局部轮询结论,
|
||||
避免 OTA「已连接」与 LAN9252「失败」同屏分裂。独立直开页无快照则用参数。 */
|
||||
try {
|
||||
var snap = deviceLink.getSnapshot();
|
||||
if (snap && snap.ts && (Date.now() - snap.ts) < 5000) {
|
||||
connected = !!snap.links.esp32.online;
|
||||
text = connected ? '已连接' : '离线';
|
||||
}
|
||||
} catch (_) {}
|
||||
var label = text || (connected ? '已连接' : '未连接');
|
||||
// 仅顶栏连接胶囊(下方连接卡不再重复显示文案)
|
||||
var pc = getEl('pillConn');
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* device-link.js — 全链路设备健康层(外壳 Owner + 功能页 Client)
|
||||
*
|
||||
* 问题:各页各自 fetch,只共用 IP 字符串 → 一页「已连接」一页「失败」。
|
||||
* 方案:tabs.html 常驻 Owner 轮询 ESP32 /system/status 与 EtherCAT /api/status
|
||||
* + /api/app/load,经 BroadcastChannel('dcu-device-link') 广播快照;
|
||||
* 功能页只订阅渲染。业务命令(SPI/CAN/OTA)仍页内发,失败不得把
|
||||
* 「设备离线」灯打红——灯只认本层。
|
||||
*
|
||||
* 角色:
|
||||
* - Owner:tabs.html 在加载本文件前设 window.__dcuDeviceLinkOwner = true
|
||||
* - Client:任意页 import { deviceLink }
|
||||
* 独立直开页面(无外壳)时 client 收不到 snapshot,可退回本地轮询。
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
var CH_NAME = "dcu-device-link";
|
||||
var ESP32_FAIL_ONLINE = 3; /* 连续失败 ≥3 判离线 */
|
||||
var ESP32_POLL_MS = 1000;
|
||||
var ECAT_POLL_MS = 2000;
|
||||
var LS_ESP_IP = "dcu_esp_ip";
|
||||
var DEFAULT_IP = "192.168.31.127";
|
||||
|
||||
function nowMs() { return Date.now(); }
|
||||
|
||||
function readIp() {
|
||||
try {
|
||||
var s = localStorage.getItem(LS_ESP_IP);
|
||||
if (s && s.trim()) return s.trim();
|
||||
} catch (_) {}
|
||||
return DEFAULT_IP;
|
||||
}
|
||||
|
||||
function writeIp(ip) {
|
||||
if (!ip || !String(ip).trim()) return;
|
||||
try { localStorage.setItem(LS_ESP_IP, String(ip).trim()); } catch (_) {}
|
||||
}
|
||||
|
||||
function emptySnapshot() {
|
||||
return {
|
||||
ts: 0,
|
||||
ip: readIp(),
|
||||
links: {
|
||||
esp32: { online: false, ageMs: -1, failStreak: 0 },
|
||||
ecat: { online: false, busOpen: false, op: false, loop: false,
|
||||
loadValid: false, loadPerm: 0, ageMs: -1 },
|
||||
ble: { online: false, deviceName: "", owner: null }
|
||||
},
|
||||
esp32Status: null
|
||||
};
|
||||
}
|
||||
|
||||
/* ───────────────────────── Owner ───────────────────────── */
|
||||
|
||||
function createOwner() {
|
||||
var ch = null;
|
||||
try { ch = new BroadcastChannel(CH_NAME); } catch (_) { ch = null; }
|
||||
var snap = emptySnapshot();
|
||||
var esp32Fails = 0;
|
||||
var lastEsp32OkAt = 0;
|
||||
var esp32Timer = null;
|
||||
var ecatTimer = null;
|
||||
var stopped = false;
|
||||
|
||||
function publish() {
|
||||
snap.ts = nowMs();
|
||||
snap.ip = readIp();
|
||||
if (ch) {
|
||||
try { ch.postMessage({ type: "snapshot", snap: snap }); } catch (_) {}
|
||||
}
|
||||
/* 同源 iframe 也可读 window 顶层缓存(BC 不可用时的弱备份) */
|
||||
try { window.__dcuDeviceLinkSnap = snap; } catch (_) {}
|
||||
}
|
||||
|
||||
function setEsp32(status, ok) {
|
||||
if (ok) {
|
||||
esp32Fails = 0;
|
||||
lastEsp32OkAt = nowMs();
|
||||
snap.links.esp32.online = true;
|
||||
snap.links.esp32.ageMs = 0;
|
||||
snap.esp32Status = status || null;
|
||||
} else {
|
||||
esp32Fails++;
|
||||
snap.links.esp32.failStreak = esp32Fails;
|
||||
snap.links.esp32.ageMs = lastEsp32OkAt ? (nowMs() - lastEsp32OkAt) : -1;
|
||||
if (esp32Fails >= ESP32_FAIL_ONLINE) snap.links.esp32.online = false;
|
||||
}
|
||||
snap.links.esp32.failStreak = esp32Fails;
|
||||
publish();
|
||||
}
|
||||
|
||||
function pollEsp32() {
|
||||
if (stopped) return;
|
||||
var ip = readIp();
|
||||
var ac = null;
|
||||
try { ac = new AbortController(); } catch (_) {}
|
||||
var tmo = null;
|
||||
fetch("http://" + ip + "/system/status" + (window._memLayout ? "" : "?layout=1"), {
|
||||
signal: ac ? ac.signal : undefined,
|
||||
cache: "no-store"
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
}).then(function (j) {
|
||||
if (ac) { clearTimeout(tmo); }
|
||||
setEsp32(j, true);
|
||||
}).catch(function () {
|
||||
if (ac) { clearTimeout(tmo); }
|
||||
setEsp32(null, false);
|
||||
}).then(function () {
|
||||
esp32Timer = setTimeout(pollEsp32, ESP32_POLL_MS);
|
||||
});
|
||||
if (ac) {
|
||||
tmo = setTimeout(function () { try { ac.abort(); } catch (_) {} }, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
function pollEcat() {
|
||||
if (stopped) return;
|
||||
var base = "/ecat/api/";
|
||||
var stP = fetch(base + "status", {
|
||||
method: "POST", body: "{}", cache: "no-store",
|
||||
signal: AbortSignal.timeout(4000)
|
||||
}).then(function (r) { return r.json(); }).catch(function () { return null; });
|
||||
var ldP = fetch(base + "app/load", {
|
||||
method: "POST", body: "{}", cache: "no-store",
|
||||
signal: AbortSignal.timeout(4000)
|
||||
}).then(function (r) { return r.json(); }).catch(function () { return null; });
|
||||
|
||||
Promise.all([stP, ldP]).then(function (arr) {
|
||||
var st = arr[0], ld = arr[1];
|
||||
var e = snap.links.ecat;
|
||||
e.online = !!(st && st.ok && st.slave);
|
||||
e.busOpen = e.online;
|
||||
/* native status 不直接给 op/loop;用 load valid + slave state 粗判 */
|
||||
e.op = !!(st && st.slave && (st.slave.state & 0x0F) === 8);
|
||||
e.loadValid = !!(ld && ld.ok && ld.valid === true
|
||||
&& Number.isFinite(ld.age_ms) && ld.age_ms >= 0 && ld.age_ms <= 3000);
|
||||
e.loop = e.loadValid || e.op;
|
||||
e.loadPerm = (ld && Number.isFinite(ld.load_perm)) ? ld.load_perm : 0;
|
||||
e.ageMs = (ld && Number.isFinite(ld.age_ms)) ? ld.age_ms : -1;
|
||||
publish();
|
||||
}).then(function () {
|
||||
ecatTimer = setTimeout(pollEcat, ECAT_POLL_MS);
|
||||
});
|
||||
}
|
||||
|
||||
function onMsg(ev) {
|
||||
var m = ev && ev.data;
|
||||
if (!m || !m.type) return;
|
||||
if (m.type === "hello") { publish(); return; }
|
||||
if (m.type === "set-ip") {
|
||||
writeIp(m.ip);
|
||||
/* 立刻重拉 ESP32(旧 timer 下一拍也会跟上) */
|
||||
try { fetch("http://" + readIp() + "/system/status", { cache: "no-store",
|
||||
signal: AbortSignal.timeout(2000) }).then(function (r) { return r.json(); })
|
||||
.then(function (j) { setEsp32(j, true); })
|
||||
.catch(function () { setEsp32(null, false); });
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
if (m.type === "ble-state") {
|
||||
snap.links.ble.online = !!m.online;
|
||||
snap.links.ble.deviceName = m.deviceName || "";
|
||||
snap.links.ble.owner = m.owner || (m.online ? "page" : null);
|
||||
publish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (ch) ch.addEventListener("message", onMsg);
|
||||
pollEsp32();
|
||||
pollEcat();
|
||||
publish();
|
||||
|
||||
return {
|
||||
stop: function () {
|
||||
stopped = true;
|
||||
if (esp32Timer) clearTimeout(esp32Timer);
|
||||
if (ecatTimer) clearTimeout(ecatTimer);
|
||||
if (ch) { try { ch.close(); } catch (_) {} }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ───────────────────────── Client ───────────────────────── */
|
||||
|
||||
function createClient() {
|
||||
var ch = null;
|
||||
try { ch = new BroadcastChannel(CH_NAME); } catch (_) { ch = null; }
|
||||
var listeners = [];
|
||||
var last = null;
|
||||
try { last = window.__dcuDeviceLinkSnap || null; } catch (_) {}
|
||||
|
||||
function emit() {
|
||||
for (var i = 0; i < listeners.length; i++) {
|
||||
try { listeners[i](last); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (ch) {
|
||||
ch.addEventListener("message", function (ev) {
|
||||
var m = ev && ev.data;
|
||||
if (!m || m.type !== "snapshot" || !m.snap) return;
|
||||
last = m.snap;
|
||||
try { window.__dcuDeviceLinkSnap = last; } catch (_) {}
|
||||
emit();
|
||||
});
|
||||
try { ch.postMessage({ type: "hello" }); } catch (_) {}
|
||||
}
|
||||
|
||||
return {
|
||||
/** 当前快照(可能为 null,Owner 尚未广播时) */
|
||||
getSnapshot: function () { return last; },
|
||||
/** 订阅快照;返回取消函数 */
|
||||
subscribe: function (fn) {
|
||||
if (typeof fn !== "function") return function () {};
|
||||
listeners.push(fn);
|
||||
if (last) { try { fn(last); } catch (_) {} }
|
||||
return function () {
|
||||
var i = listeners.indexOf(fn);
|
||||
if (i >= 0) listeners.splice(i, 1);
|
||||
};
|
||||
},
|
||||
setIp: function (ip) {
|
||||
writeIp(ip);
|
||||
if (ch) { try { ch.postMessage({ type: "set-ip", ip: readIp() }); } catch (_) {} }
|
||||
},
|
||||
getIp: function () { return readIp(); },
|
||||
reportBle: function (online, deviceName) {
|
||||
var payload = {
|
||||
type: "ble-state",
|
||||
online: !!online,
|
||||
deviceName: deviceName || "",
|
||||
owner: "iframe"
|
||||
};
|
||||
if (ch) { try { ch.postMessage(payload); } catch (_) {} }
|
||||
/* 本地立刻改一份,单页直开也能看到 */
|
||||
if (last) {
|
||||
last.links.ble.online = !!online;
|
||||
last.links.ble.deviceName = deviceName || "";
|
||||
last.links.ble.owner = online ? "iframe" : null;
|
||||
last.ts = nowMs();
|
||||
emit();
|
||||
}
|
||||
},
|
||||
/** 是否有外壳 Owner 在管(收到过 snapshot 或自身是 owner) */
|
||||
hasOwner: function () { return !!last; }
|
||||
};
|
||||
}
|
||||
|
||||
/* ───────────────────────── bootstrap ───────────────────────── */
|
||||
|
||||
var deviceLink;
|
||||
try {
|
||||
if (window.__dcuDeviceLinkOwner) {
|
||||
window.__dcuDeviceLinkInstance = createOwner();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
deviceLink = createClient();
|
||||
export { deviceLink };
|
||||
export default deviceLink;
|
||||
Reference in New Issue
Block a user