/** * device-link.js — 全链路连接中心(ConnectionHub) * * 统一管理页面上的 WiFi / EtherCAT / BLE 三条链路: * - 健康轮询:外壳 Owner 常驻拉 ESP32 /system/status 与 EtherCAT status+load * - 端点配置:设备 IP(dcu_esp_ip)、网卡子串(dcu_ecat_adapter)只在此读写 * - 通道适配:wifiBaseUrl / ecatPost 供各页复用,禁止再散落 localStorage * * 角色: * - Owner:tabs.html 加载前设 window.__dcuDeviceLinkOwner = true * - Client:任意页 import { deviceLink } * 独立直开页无 Owner 时,client 仍可读 IP / 发 ecatPost,只是无跨页快照。 * * 命令仍由业务页自己发;连接灯只认本层健康态(布尔优先,文案保留调用方语义)。 */ "use strict"; import { getDeviceIp, saveDeviceIp, getBaseUrl as baseUrlFromIp } from "./base-url.js"; var CH_NAME = "dcu-device-link"; var ESP32_FAIL_ONLINE = 3; /* 连续失败 ≥3 判离线 */ var ESP32_POLL_MS = 1000; var ECAT_POLL_MS = 2000; var LS_ECAT_ADAPTER = "dcu_ecat_adapter"; var DEFAULT_ECAT_ADAPTER = "I210"; function nowMs() { return Date.now(); } function readIp() { return getDeviceIp(); } function writeIp(ip) { saveDeviceIp(ip); } function readAdapter() { try { var s = localStorage.getItem(LS_ECAT_ADAPTER); if (s && s.trim()) return s.trim(); } catch (_) {} return DEFAULT_ECAT_ADAPTER; } function writeAdapter(substr) { if (!substr || !String(substr).trim()) return; try { localStorage.setItem(LS_ECAT_ADAPTER, String(substr).trim()); } catch (_) {} } /** * 统一 /ecat/api/* 代理请求(同源,esp32_server 转发到 ecat_native)。 * @param {string} path 相对路径,如 "status" / "app/load" / "open" * @param {object} body JSON body,缺省 {} * @param {number} timeoutMs 默认 5000 * @returns {Promise} 解析后的 JSON */ async function ecatPost(path, body, timeoutMs) { var p = String(path || "").replace(/^\/+/, ""); var resp = await fetch("/ecat/api/" + p, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}), cache: "no-store", signal: AbortSignal.timeout(timeoutMs || 5000) }); var text = await resp.text(); var json = {}; try { json = text ? JSON.parse(text) : {}; } catch (_) { throw new Error("响应非合法 JSON"); } if (!resp.ok) { var err = new Error("HTTP " + resp.status); err.status = resp.status; err.json = json; throw err; } return json; } 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 ac = null; try { ac = new AbortController(); } catch (_) {} var tmo = null; fetch(baseUrlFromIp() + "/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 stP = ecatPost("status", {}, 4000).catch(function () { return null; }); var ldP = ecatPost("app/load", {}, 4000).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); try { fetch(baseUrlFromIp() + "/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 (_) {} } function applyLocalBle(online, deviceName) { if (last) { last.links.ble.online = !!online; last.links.ble.deviceName = deviceName || ""; last.links.ble.owner = online ? "iframe" : null; last.ts = nowMs(); emit(); } } return { /* ── 快照 ── */ 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); }; }, /** 指定通道当前是否在线;无快照时回退 false(调用方自行降级) */ channelOnline: function (name) { if (!last || !last.links) return false; var L = last.links[name]; return !!(L && L.online); }, /* ── WiFi 端点 ── */ setIp: function (ip) { writeIp(ip); if (ch) { try { ch.postMessage({ type: "set-ip", ip: readIp() }); } catch (_) {} } }, getIp: function () { return readIp(); }, wifiBaseUrl: function () { return baseUrlFromIp(); }, /* ── EtherCAT 端点与请求 ── */ getEcatAdapter: function () { return readAdapter(); }, setEcatAdapter: function (substr) { writeAdapter(substr); return readAdapter(); }, ecatPost: ecatPost, /* ── BLE 状态上报 ── */ reportBle: function (online, deviceName) { var payload = { type: "ble-state", online: !!online, deviceName: deviceName || "", owner: "iframe" }; if (ch) { try { ch.postMessage(payload); } catch (_) {} } applyLocalBle(online, deviceName); }, hasOwner: function () { return !!last; } }; } /* ───────────────────────── bootstrap ───────────────────────── */ var deviceLink; try { if (window.__dcuDeviceLinkOwner) { window.__dcuDeviceLinkInstance = createOwner(); } } catch (_) {} deviceLink = createClient(); export { deviceLink, ecatPost, readAdapter as getEcatAdapter, writeAdapter as setEcatAdapter }; export default deviceLink;