/** * 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;