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

535 lines
20 KiB
JavaScript
Raw Normal View History

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