Files
DCU_DesktopController/js/common/device-link.js
T

262 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 (_) {}
}
}
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 != null && (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();
}).catch(function () {
/* 字段访问/异常不得让 ECAT 轮询永久停摆 */
}).then(function () {
if (!stopped) 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;
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;
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;