DeviceLink Owner 在控制台加载后自动 bootstrapEcatBus:status→open→ motor/start,沿用 hub 网卡与 period/spin 持久化。成功后不重复 start; 掉线 10s 退避重开。有线 ECAT 与 WiFi 不冲突。
376 lines
12 KiB
JavaScript
376 lines
12 KiB
JavaScript
/**
|
||
* 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 ECAT_BUS_RETRY_MS = 10000; /* 开总线失败后的重试间隔 */
|
||
var LS_ECAT_PERIOD = "dcu_ecat_period_ms";
|
||
var LS_ECAT_SPIN = "dcu_ecat_spin";
|
||
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<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 },
|
||
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;
|
||
/* 外壳启动即强制拉起总线(open + motor/start),不必等电机页切 EtherCAT。
|
||
* 成功后不再重复 motor/start;掉线后按 ECAT_BUS_RETRY_MS 退避重试。 */
|
||
var ecatBusReady = false;
|
||
var ecatBusBusy = false;
|
||
var ecatBusNextTryAt = 0;
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 开总线:status →(未开则)open 网卡 → motor/start 进 OP + 起 PDO 循环。
|
||
* 与 motor/transport-ecat ensureOpen/ensureStarted 同源,由外壳 Owner 在
|
||
* 控制台启动时强制执行,用户不必先去电机页把链路切到 EtherCAT。
|
||
*/
|
||
function bootstrapEcatBus() {
|
||
if (stopped || ecatBusBusy) return;
|
||
var now = nowMs();
|
||
if (ecatBusReady) return;
|
||
if (now < ecatBusNextTryAt) return;
|
||
ecatBusBusy = true;
|
||
ecatBusNextTryAt = now + ECAT_BUS_RETRY_MS;
|
||
|
||
var adapter = readAdapter();
|
||
var body = {};
|
||
try {
|
||
var v = parseFloat(localStorage.getItem(LS_ECAT_PERIOD));
|
||
if (v > 0) body.period_ms = v;
|
||
var sp = localStorage.getItem(LS_ECAT_SPIN);
|
||
if (sp === "0") body.spin = false;
|
||
else if (sp === "1") body.spin = true;
|
||
} catch (_) {}
|
||
|
||
ecatPost("status", {}, 6000).then(function (st) {
|
||
var open = !!(st && st.ok);
|
||
return open ? Promise.resolve(st)
|
||
: ecatPost("open", { substr: adapter }, 20000);
|
||
}).then(function (r) {
|
||
if (r && r.ok === false) {
|
||
throw new Error((r.log && r.log.join(" / ")) || "open 失败");
|
||
}
|
||
return ecatPost("motor/start", body, 30000);
|
||
}).then(function (r) {
|
||
if (r && r.ok === false) {
|
||
throw new Error((r.log && r.log.join(" / ")) || "motor/start 失败");
|
||
}
|
||
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);
|
||
/* 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;
|
||
if (e.busOpen && e.loop) ecatBusReady = true;
|
||
else if (ecatBusReady && !e.busOpen) ecatBusReady = false; /* 总线掉了 → 允许重开 */
|
||
publish();
|
||
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);
|
||
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 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;
|