Files
DCU_DesktopController/js/common/device-link.js
T
cbb eac3717f9a feat: 添加局域网设备发现功能和链路胶囊优化
- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点
- 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果
- 实现快扫和全段扫描两种模式,支持种子地址优先探测
- 添加端点唯一写手机制,防止多处同时修改设备IP
- 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称
- 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题
- 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒
- 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
2026-10-13 00:48:34 +08:00

535 lines
20 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 — 全链路连接中心(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<object>} 解析后的 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;