feat(link): ConnectionHub 统一 WiFi/ECAT/BLE 端点与健康态

各页不再各自读写 dcu_esp_ip / dcu_ecat_adapter:deviceLink 提供
wifiBaseUrl/ecatPost/adapter API,OTA/CAN/motor/ecat_load/lab 走同一入口。
连接灯只覆盖在线布尔,保留调用方语义文案。
This commit is contained in:
cbb
2026-09-16 12:12:24 +08:00
parent 353d936abb
commit 033ece2cca
11 changed files with 210 additions and 110 deletions
+103 -45
View File
@@ -1,39 +1,82 @@
/**
* device-link.js — 全链路设备健康层(外壳 Owner + 功能页 Client)
* device-link.js — 全链路连接中心(ConnectionHub)
*
* 问题:各页各自 fetch,只共用 IP 字符串 → 一页「已连接」一页「失败」。
* 方案:tabs.html 常驻 Owner 轮询 ESP32 /system/status 与 EtherCAT /api/status
* + /api/app/load,经 BroadcastChannel('dcu-device-link') 广播快照;
* 功能页只订阅渲染。业务命令(SPI/CAN/OTA)仍页内发,失败不得把
* 「设备离线」灯打红——灯只认本层。
* 统一管理页面上的 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
* - Owner:tabs.html 加载前设 window.__dcuDeviceLinkOwner = true
* - Client:任意页 import { deviceLink }
* 独立直开页面(无外壳)时 client 收不到 snapshot,可退回本地轮询。
* 独立直开页无 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_ESP_IP = "dcu_esp_ip";
var DEFAULT_IP = "192.168.31.127";
var LS_ECAT_ADAPTER = "dcu_ecat_adapter";
var DEFAULT_ECAT_ADAPTER = "I210";
function nowMs() { return Date.now(); }
function readIp() {
try {
var s = localStorage.getItem(LS_ESP_IP);
if (s && s.trim()) return s.trim();
} catch (_) {}
return DEFAULT_IP;
return getDeviceIp();
}
function writeIp(ip) {
if (!ip || !String(ip).trim()) return;
try { localStorage.setItem(LS_ESP_IP, String(ip).trim()); } catch (_) {}
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() {
@@ -89,11 +132,10 @@ function createOwner() {
function pollEsp32() {
if (stopped) return;
var ip = readIp();
var ac = null;
try { ac = new AbortController(); } catch (_) {}
var tmo = null;
fetch("http://" + ip + "/system/status" + (window._memLayout ? "" : "?layout=1"), {
fetch(baseUrlFromIp() + "/system/status" + (window._memLayout ? "" : "?layout=1"), {
signal: ac ? ac.signal : undefined,
cache: "no-store"
}).then(function (r) {
@@ -115,15 +157,8 @@ function createOwner() {
function pollEcat() {
if (stopped) return;
var base = "/ecat/api/";
var stP = fetch(base + "status", {
method: "POST", body: "{}", cache: "no-store",
signal: AbortSignal.timeout(4000)
}).then(function (r) { return r.json(); }).catch(function () { return null; });
var ldP = fetch(base + "app/load", {
method: "POST", body: "{}", cache: "no-store",
signal: AbortSignal.timeout(4000)
}).then(function (r) { return r.json(); }).catch(function () { return null; });
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];
@@ -151,11 +186,13 @@ function createOwner() {
if (m.type === "hello") { publish(); return; }
if (m.type === "set-ip") {
writeIp(m.ip);
/* 立刻重拉 ESP32(旧 timer 下一拍也会跟上) */
try { fetch("http://" + readIp() + "/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); });
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;
}
@@ -207,10 +244,19 @@ function createClient() {
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 {
/** 当前快照(可能为 null,Owner 尚未广播时) */
/* ── 快照 ── */
getSnapshot: function () { return last; },
/** 订阅快照;返回取消函数 */
subscribe: function (fn) {
if (typeof fn !== "function") return function () {};
listeners.push(fn);
@@ -220,11 +266,30 @@ function createClient() {
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",
@@ -233,16 +298,9 @@ function createClient() {
owner: "iframe"
};
if (ch) { try { ch.postMessage(payload); } catch (_) {} }
/* 本地立刻改一份,单页直开也能看到 */
if (last) {
last.links.ble.online = !!online;
last.links.ble.deviceName = deviceName || "";
last.links.ble.owner = online ? "iframe" : null;
last.ts = nowMs();
emit();
}
applyLocalBle(online, deviceName);
},
/** 是否有外壳 Owner 在管(收到过 snapshot 或自身是 owner) */
hasOwner: function () { return !!last; }
};
}
@@ -257,5 +315,5 @@ try {
} catch (_) {}
deviceLink = createClient();
export { deviceLink };
export { deviceLink, ecatPost, readAdapter as getEcatAdapter, writeAdapter as setEcatAdapter };
export default deviceLink;