/** * 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, isValidIpv4, getDeviceMeta, saveDeviceMeta } from "./base-url.js"; import { flashBusyNow } from "./utils.js"; import { runEcatBootstrap, repairEcatDc, getEcatAdapterOrDefault, ECAT_DEFAULT_ADAPTER, } from "./ecat-bus.js"; var CH_NAME = "dcu-device-link"; var ESP32_FAIL_ONLINE = 3; /* 连续失败 ≥3 判离线 */ var ESP32_POLL_MS = 1000; var ECAT_POLL_MS = 2000; var ECAT_BUS_RETRY_MS = 10000; /* 开总线失败后的重试间隔 */ var AUTO_SCAN_COOLDOWN_MS = 60000; /* 判离线后最多每 60s 提一次候选设备 */ var ecatDcGuardBusy = false; /* DC/周期自愈 in-flight */ var LS_ECAT_PERIOD = "dcu_ecat_period_ms"; var LS_ECAT_SPIN = "dcu_ecat_spin"; var LS_ECAT_ADAPTER = "dcu_ecat_adapter"; /* R6:网卡默认常量只在 ecat-bus.js 定义;此处 re-export 保持旧 import 路径可用 */ var DEFAULT_ECAT_ADAPTER = ECAT_DEFAULT_ADAPTER; function nowMs() { return Date.now(); } function readIp() { return getDeviceIp(); } function writeIp(ip) { /* 校验在 base-url.saveDeviceIp 单点做:非法值不改写现值,返回 false。 */ return saveDeviceIp(ip); } function readAdapter() { try { var s = localStorage.getItem(LS_ECAT_ADAPTER); if (s && s.trim()) return s.trim(); } catch (_) {} return getEcatAdapterOrDefault(); } 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, deviceName: "", slaveState: null, alStatus: null }, ble: { online: false, deviceName: "", owner: null } }, esp32Status: null, /* 端点一致性:'match' | 'mismatch' | 'unverified'(见 checkEndpoint) */ endpointCheck: 'unverified', endpointCheckNote: '' }; } /* ───────────────────────── 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; /* 外壳启动即强制拉起总线(open + motor/start),不必等电机页切 EtherCAT。 * 成功后不再重复 motor/start;掉线后按 ECAT_BUS_RETRY_MS 退避重试。 */ var ecatBusReady = false; var ecatBusBusy = false; var ecatBusNextTryAt = 0; var uptimeBase = null; /* {ip, up} 只用于 uptime 倒退检测(换板/复位) */ var autoScanAt = 0; /* 上次自动提候选的时刻,见 maybeAutoScan */ 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; snap.endpointCheck = checkEndpoint(status); } 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.endpointCheck = checkEndpoint(null); maybeAutoScan(); } } snap.links.esp32.failStreak = esp32Fails; publish(); } /** * 「现在连的还是我选的那台吗」——判据必须有灵敏度,不能是自证: * 版本变 / 设备自报 IP 与所选不符 / uptime 倒退,三条都能被 * "同地址换了一块板"或"板子刚复位"真实触发。 * 拉不到 status 时**不改判**(保持上一次结论),否则断网会把 * mismatch 说成事实。 */ function checkEndpoint(status) { var meta = getDeviceMeta(); var ip = readIp(); if (!meta || String(meta.ip) !== String(ip)) return 'unverified'; if (!status) { /* 拉不到 status 就**没有**证据可判:不能沿用上一条结论,更不能把 * "上一个端点的 status" 拿来判当前端点(实测会吐出一条自造的 * "设备自报 x.x.x.x 与所选 y.y.y.y 不符"——那是陈旧数据的回声)。 */ snap.endpointCheckNote = ''; return 'unverified'; } var w = status.wifi || {}; if (status.version && meta.version && String(status.version) !== String(meta.version)) { snap.endpointCheckNote = '版本与所选不符:' + meta.version + ' → ' + status.version; return 'mismatch'; } if (w.connected === true && w.ip && String(w.ip) !== String(ip)) { snap.endpointCheckNote = '设备自报 ' + w.ip + ',与所选 ' + ip + ' 不符'; return 'mismatch'; } var up = Number(status.uptime_sec); if (Number.isFinite(up)) { if (uptimeBase && uptimeBase.ip === ip && Number.isFinite(uptimeBase.up) && up + 2 < uptimeBase.up) { snap.endpointCheckNote = 'uptime ' + Math.round(uptimeBase.up) + 's → ' + Math.round(up) + 's:这地址上换板了或刚复位'; uptimeBase = { ip: ip, up: up }; return 'mismatch'; } uptimeBase = { ip: ip, up: up }; } snap.endpointCheckNote = ''; return status.version ? 'match' : 'unverified'; } /** * 离线了才提一次候选,**绝不自动改端点**。三个门都是必需的: * 扫描本身会占板子 httpd 的 socket(max_open_sockets=6 + lru_purge, * wifi_ota.cpp:419-436),而 ECAT 环跑 / 刷写期间 httpd 常常本就不响应 * (spi_master.cpp:768-771)——那时 failStreak 是假阳性,不加门就成了 * "扫→踢连接→更离线→再扫"的正反馈。 */ function maybeAutoScan() { var now = nowMs(); if (autoScanAt && now - autoScanAt < AUTO_SCAN_COOLDOWN_MS) return; if (flashBusyNow()) return; if (snap.links.ecat && snap.links.ecat.loop) return; autoScanAt = now; if (ch) { try { ch.postMessage({ type: 'auto-scan', ip: readIp() }); } catch (_) {} } } 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); } } /** * 开总线:R6 —— 策略只在 js/common/ecat-bus.js(health→open→motor/start, * DC 250µs,已健康则复用)。外壳 Owner 启动时强制执行,用户不必先去电机页。 * BOOT/OTA 刷写中禁止重拉:motor/start 会 loop_stop,会掐死灌写会话。 */ function bootstrapEcatBus() { if (stopped || ecatBusBusy) return; /* BOOT/OTA 刷写中禁止重拉:motor/start 会 loop_stop,会掐死灌写会话。 * motorOtaEcatBusy = 电机 OTA 正经邮箱灌 GD32(native worker 独占总线十几秒), * 这条请求只会在锁上排队,排到时把刚恢复的循环再停一遍。 */ if (window.bootEcatBusy || window.isUploading || window.motorOtaEcatBusy) return; /* 对面是 BOOT(邮箱 PREOP、不跑 OP/PDO)时不要 motor/start: * 跳转/刷写结束后 bootEcatIsBoot 会钉住,直到 jump_app 确认 APP。 */ if (window.bootEcatIsBoot === true) return; var now = nowMs(); if (ecatBusReady) return; if (now < ecatBusNextTryAt) return; ecatBusBusy = true; ecatBusNextTryAt = now + ECAT_BUS_RETRY_MS; var adapter = readAdapter(); var period = null; var spin = null; try { period = parseFloat(localStorage.getItem(LS_ECAT_PERIOD)); var sp = localStorage.getItem(LS_ECAT_SPIN); if (sp === "0") spin = false; else if (sp === "1") spin = true; } catch (_) {} runEcatBootstrap({ ecatPost: ecatPost, adapter: adapter, period_ms: period, spin: spin, }).then(function (r) { if (r && r.ok) ecatBusReady = true; }).catch(function () { /* 网线没插/从站未上电:退避后由 pollEcat 再触发 */ }).then(function () { ecatBusBusy = false; }); } 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 = !!(st && st.ok); e.deviceName = (st && st.slave && st.slave.name) ? String(st.slave.name) : ""; /* 从站 AL 状态原样带上,让灯色与文案同源:1=INIT 2=PREOP 3=BOOT 4=SAFEOP 8=OP。 * 只有 ok+slave 就判"在线"太松——实测从站在 INIT、0x1008 空、循环没跑时 * 也会被点成绿灯,而文字还写着"离线"。 */ e.slaveState = (st && st.slave && st.slave.state != null) ? Number(st.slave.state) : null; e.alStatus = (st && st.slave && st.slave.al_status != null) ? Number(st.slave.al_status) : null; /* 同步到 window:Client 侧 updateLinkPills 在旧快照缺字段时兜底读取 */ window.__ecatDeviceName = e.deviceName; /* 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; /* 与刚上电同判据:bus_open + loop 才算就绪。 * jump_app / commit 后 loop 被 loop_stop 掉、bus 仍开 —— 旧逻辑只在 * !busOpen 时清 ready,于是 Owner 永远不再 motor/start,连接要等外部 * 自愈才回来。loop 一掉就允许 bootstrap 重拉(刷写中由上面 busy 门拦住)。 */ if (e.busOpen && e.loop) ecatBusReady = true; else ecatBusReady = false; publish(); /* 自愈:DC/周期被配错时发送出口秒级 —— R6 单点 repairEcatDc */ if (e.busOpen && e.loop && !ecatDcGuardBusy) { ecatDcGuardBusy = true; repairEcatDc(ecatPost).catch(function () {}).then(function () { ecatDcGuardBusy = false; }); } bootstrapEcatBus(); }).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); /* 换了端点=换了问题:旧的核对结论必须作废,否则新端点还没答过话, 面板就拿着上一个设备的 status 报"不符"。 */ snap.endpointCheck = 'unverified'; snap.endpointCheckNote = ''; uptimeBase = null; /* uptime 倒退检测只在"同一个端点连续观察"内成立 */ /* 无论采纳还是拒绝都把"实际生效的 IP"广播回去:否则各页输入框会停在 用户刚打的脏值上,显示连着、实际打的还是旧端点——那正是自证判据。 */ publish(); 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(); bootstrapEcatBus(); 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 autoScanFns = []; var last = null; function emitAutoScan(m) { for (var i = 0; i < autoScanFns.length; i++) { try { autoScanFns[i](m || {}); } 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) return; if (m.type === "auto-scan") { emitAutoScan(m); return; } if (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 端点(唯一写手;各页输入框/发现面板都走这里) ── */ /** * @description 换设备。**唯一**能改当前端点的入口,门也只在这里判一次: * - IPv4 合法性(base-url.saveDeviceIp 单点校验,非法不改写现值) * - 刷写/OTA 进行中不换(motor/start 会 loop_stop、灌写会话会被掐死) * @returns {{ok:boolean, ip?:string, reason?:string}} */ setIp: function (ip) { var v = String(ip == null ? '' : ip).trim(); if (!isValidIpv4(v)) return { ok: false, reason: '不是可用 IPv4:' + v }; if (flashBusyNow()) return { ok: false, reason: '刷写/OTA 进行中,暂不换端点' }; if (!writeIp(v)) return { ok: false, reason: '写入 localStorage 失败' }; if (ch) { try { ch.postMessage({ type: "set-ip", ip: readIp() }); } catch (_) {} } return { ok: true, ip: readIp() }; }, /** * @description 选中一台发现到的设备:比 setIp 多一步指纹登记(版本/来源/时刻), * Owner 之后每次拉 status 都据此核"连的还是不是这台"(snap.endpointCheck)。 * @param {{ip:string, version?:string, git_hash?:string, source?:string}} rec */ selectDevice: function (rec) { var ip = rec && rec.ip != null ? String(rec.ip).trim() : ''; var r = this.setIp(ip); if (!r.ok) return r; saveDeviceMeta({ ip: r.ip, version: rec.version ? String(rec.version) : '', git_hash: rec.git_hash ? String(rec.git_hash) : '', observedAt: nowMs(), source: rec.source || 'lan' }); return r; }, /** @description 当前登记的端点指纹(无则 null)。 */ getDeviceMeta: function () { return getDeviceMeta(); }, /** @description 订阅"Owner 判离线、提候选"的信号(不自动改端点)。 */ onAutoScan: function (fn) { if (typeof fn !== 'function') return function () {}; autoScanFns.push(fn); return function () { var i = autoScanFns.indexOf(fn); if (i >= 0) autoScanFns.splice(i, 1); }; }, 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) { /* 把标记消费掉:同一文档里本模块若因入口 ?v= 与 JS 间 import 的 URL 不同被 * 实例化两次(tabs.html 就是这种:入口标签带 stamp,lan-picker.js 不带), * 只允许有一个 Owner —— 两个 Owner 就是两份 1Hz status 轮询 + 两份快照互相打架。 */ window.__dcuDeviceLinkOwner = false; window.__dcuDeviceLinkInstance = createOwner(); } } catch (_) {} deviceLink = createClient(); export { deviceLink, ecatPost, readAdapter as getEcatAdapter, writeAdapter as setEcatAdapter }; export { ECAT_DEFAULT_ADAPTER }; export default deviceLink;