Files
DCU_DesktopController/js/common/device-link.js
T
cbb 383535bd1d feat(web): 桌面原生桥 + 配置落盘 + 无设备静默态;排除调试脚本
变更点:
1. esp32_server.py
   - /api/motor/ui-state、/api/lab/ui-state:配置原子落盘 data/*.json(WebView2 清存储仍在)
   - _GuiApi.ecat_api/bridge_info:桌面壳经 GUI 进程裸 socket 直连 ecat_native,不经浏览器 HTTP
2. js/common/desktop-bridge.js(新)
   - 探测 pywebview.api(含 iframe top/parent),transport-ecat 优先走原生桥
3. connection.js + responsive.css + components.css
   - setQuietMode:离线且无传输时 html[data-dcu-calm=1] 掐常驻动画
   - 进度条斜纹仅 .live 挂载,空闲不再按刷新率空转重绘
4. device-link.js
   - 先查 native/health,已健康跳过 motor/start,避免 loop_stop 重进 OP 假超时
   - 周期强制 0.25ms + DC SYNC0 对齐;轮询自愈被配错的 DC/周期
5. motor/main.js + transport-ecat.js
   - 配置双写 localStorage + 磁盘 ui-state;pagehide/sendBeacon 刷盘;导入导出
   - 探测代际 probeGen;ECAT 失败阈值 12;跟踪 bootActive/slaveAl/slaveOp
6. lab/spi_test.html、ota/boot_ecat.js、can_monitor 等跟随桥/静默态
7. .gitignore
   - 排除 scripts/_* 与 anal/collect/measure/trace 等调试脚本、lab 产物、data/ 运行态
2026-09-20 22:58:00 +08:00

414 lines
14 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
} 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 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";
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);
}
}
/**
* 开总线:health →(未就绪则)status →(未开则)open 网卡 → motor/start 进 OP + 起 PDO 循环。
* 与 motor/transport-ecat ensureOpen/ensureStarted 同源,由外壳 Owner 在
* 控制台启动时强制执行,用户不必先去电机页把链路切到 EtherCAT。
*
* 已健康(bus_open + loop)时**直接复用**,不打 motor/start:
* 那条路由会先 loop_stop 再重进 OP,和电机页探测撞车时会假超时
* (横幅 signal timed out)并平白增加 SSC 楔死风险。
*/
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));
/* 无显式值 / 值过慢 → 固定 0.25ms。1ms+DC 会让发送出口回到 ~1s。 */
if (v > 0 && v <= 0.5) body.period_ms = v;
else body.period_ms = 0.25;
var sp = localStorage.getItem(LS_ECAT_SPIN);
if (sp === "0") body.spin = false;
else if (sp === "1") body.spin = true;
} catch (_) { body.period_ms = 0.25; }
/* EtherCAT 控制必须 DC SYNC0≈PDO 周期,且周期应保持 250µs 档 */
{
var per = Number(body.period_ms) || 0.25;
if (per > 0.5) per = 0.25;
body.period_ms = per;
var us = Math.round(per * 1000);
if (us < 250) us = 250;
body.dc_cycle_us = us;
body.dc_shift_us = 0;
}
ecatPost("native/health", {}, 3000).then(function (h) {
if (h && h.ok && h.bus_open && h.loop) {
ecatBusReady = true;
return null; /* 已在跑:跳过 open + motor/start */
}
return 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 失败");
}
if (r) 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();
/* 自愈:DC/周期被配错时发送出口秒级 —— 2s 轮询里拉回 250µs */
if (e.busOpen && e.loop && !ecatDcGuardBusy) {
ecatDcGuardBusy = true;
ecatPost("native/health", {}, 2000).then(function (h) {
if (!h || !h.ok) return null;
var dcs = Number(h.dc_cycle_us);
var per = Number(h.period_ms);
if (h.dc === 1 && dcs === 250 && (h.period_ms == null || Math.abs(per - 0.25) < 0.001)) return null;
return ecatPost("motor/start", {
period_ms: 0.25, dc_cycle_us: 250, dc_shift_us: 0
}, 30000);
}).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);
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;