Files
DCU_DesktopController/js/common/ecat-bus.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

349 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.
/**
* ecat-bus.js — EtherCAT 总线 bootstrap / open / motor-start 唯一实现
*
* 设备链路(device-link 外壳 Owner)与电机传输(transport-ecat)都调用本模块,
* 策略只保留一份:
* health →(未就绪)status →(未开)open 网卡 → motor/start 进 OP + 起 PDO 循环
*
* 红线:
* - PDO 周期与 DC SYNC0 **始终同值**:dc_cycle_us = round(period_ms*1000),
* 周期可在 UI 档位 0.125–4ms 内随时热改,不再钉死 0.25。
* 默认仍是 0.25ms(历史稳妥档);≥1ms 时 GD32 发送出口可能变慢,属预期代价。
* - 幂等:native/health 报 bus_open+loop 时**复用**,不再 motor/start
* (start 会先 loop_stop,热路径假超时 / SSC 楔死风险)
* - **自动化只能基于证明,人才能基于意图**:任何非用户直接触发的 motor/start
* 都必须先过 proveEcatLoop / proveEcatDc,证明"确实需要动"才动;证明不了
* (!ok / 缺字段 / 正在恢复)一律不动总线。合法 reason 只有四个:
* user_probe / user_period / auto_loop_repair / auto_dc_repair。
* 为什么这么严:motor/start 内部先 loop_stop,停循环 >1000ms 就从站过程数据
* 看门狗踢出 OP;反复这样会把 SSC 邮箱楔到只能断电(历史多次踩过)。
* - 网卡默认常量 ECAT_DEFAULT_ADAPTER 只在此定义
*/
"use strict";
/** 网卡匹配子串默认值(代码/页面/测试的唯一来源;文档漂移以此为准)。 */
export const ECAT_DEFAULT_ADAPTER = "I210";
/** 默认 PDO 周期(ms)。 */
export const ECAT_DEFAULT_PERIOD_MS = 0.25;
/** 允许的 PDO 周期上下限(ms)——与 spi_test 档位一致,可随时热改。 */
export const ECAT_PERIOD_MIN_MS = 0.125;
export const ECAT_PERIOD_MAX_MS = 4;
/** 默认 DC SYNC0 周期(µs):与默认 period 0.25ms 对齐;热改后 DC 跟随 period。 */
export const ECAT_DC_CYCLE_US = 250;
/** 把任意 period 钳进合法区间;非法/缺省回落默认 0.25ms。 */
export function clampEcatPeriodMs(v) {
var per = Number(v);
if (!(per > 0)) return ECAT_DEFAULT_PERIOD_MS;
if (per < ECAT_PERIOD_MIN_MS) return ECAT_PERIOD_MIN_MS;
if (per > ECAT_PERIOD_MAX_MS) return ECAT_PERIOD_MAX_MS;
return per;
}
/** period_ms → DC SYNC0 µs(与 period 锁死同值,不再是恒 250)。 */
export function periodToDcUs(perMs) {
return Math.round(clampEcatPeriodMs(perMs) * 1000);
}
const LS_ECAT_PERIOD = "dcu_ecat_period_ms";
const LS_ECAT_SPIN = "dcu_ecat_spin";
const LS_ECAT_ADAPTER = "dcu_ecat_adapter";
const LS_PERIOD_MIG = "dcu_ecat_period_mig";
function readAdapterRaw() {
try {
var s = localStorage.getItem(LS_ECAT_ADAPTER);
if (s && s.trim()) return s.trim();
} catch (_) {}
return ECAT_DEFAULT_ADAPTER;
}
/**
* 构造 motor/start body:period 钳进 0.125–4ms,DC 与 period 同值。
* @param {{period_ms?:number, spin?:boolean|null, fromLocalStorage?:boolean}} [opts]
*/
export function resolveEcatStartBody(opts) {
opts = opts || {};
var body = {};
var per = clampEcatPeriodMs(opts.period_ms);
body.period_ms = per;
body.dc_cycle_us = periodToDcUs(per);
body.dc_shift_us = 0;
var spin = opts.spin;
if (spin == null && opts.fromLocalStorage) {
try {
var sp = localStorage.getItem(LS_ECAT_SPIN);
if (sp === "0") spin = false;
else if (sp === "1") spin = true;
} catch (_) {}
}
if (spin === true || spin === false) body.spin = spin;
return body;
}
/** 从 localStorage 解析 period;一次性清理 Python 时代误存的 "4"。 */
export function readStoredEcatPeriod() {
try {
if (!localStorage.getItem(LS_PERIOD_MIG)) {
if (parseFloat(localStorage.getItem(LS_ECAT_PERIOD)) === 4) {
localStorage.removeItem(LS_ECAT_PERIOD);
}
localStorage.setItem(LS_PERIOD_MIG, "1");
}
var v = parseFloat(localStorage.getItem(LS_ECAT_PERIOD));
/* 用户显式设过且落在合法档位内才用;否则回落默认 0.25ms */
if (v > 0 && v >= ECAT_PERIOD_MIN_MS && v <= ECAT_PERIOD_MAX_MS) return v;
return ECAT_DEFAULT_PERIOD_MS;
} catch (_) {
return ECAT_DEFAULT_PERIOD_MS;
}
}
/**
* 「后台 PDO 循环到底在不在跑」的唯一判据。纯函数:不 fetch、不读 localStorage、
* 不动任何状态 —— 所以能被 test_ecat_autostart.mjs 离线逐例判死。
*
* 为什么需要第三态 'unknown':只有 up/down 两值时,"读不到/字段缺失"必然落到
* 其中一边,落到 down 就等于"仪表坏了就重启总线"。历史上 healthCheck 的内联判据
* 正是这个形状(把"没有 dc 字段"当成"DC 配错"),于是一篇错误回包就打出一记
* loop_stop。unknown 一律禁止自动化动手。
*
* @param {any} h native/health 的回包(或 null=请求失败)
* @param {{treatOfflineAsDown?:boolean}} [opts] 仅给"本来就要负责把总线拉起来"的页面
* (OTA 负载页)用:连不上/未开网卡 ⇒ 没有任何健康循环会被我们停掉,开它是安全的。
* @returns {"up"|"down"|"unknown"}
*/
export function proveEcatLoop(h, opts) {
if (!h || h.ok !== true) {
return (opts && opts.treatOfflineAsDown) ? "down" : "unknown";
}
if (h.bus_open == null || h.loop == null) return "unknown";
if (h.recovering) return "unknown"; /* native 正在全量重连:再插一记 start 是自相残杀 */
if (!h.bus_open) return "down"; /* 网卡都没开,不存在被我们停掉的健康循环 */
return h.loop ? "up" : "down";
}
/**
* 「DC/周期是否被*证明*配错」。缺字段 = 证明不了 = 不动总线(repairEcatDc 旧规则收编于此)。
* 判据:motor 路径必须 DC on;period / dc_cycle_us 各自在合法区间;
* 两者都有值时必须同值(period_ms*1000 ≈ dc_cycle_us)。
* 周期本身可以是 0.125–4ms 内任意档 —— 与 period 锁死的 DC 不再判错。
* @returns {"ok"|"misconfigured"|"unknown"}
*/
export function proveEcatDc(h) {
if (!h || h.ok !== true) return "unknown";
if (h.dc == null && h.period_ms == null && h.dc_cycle_us == null) return "unknown";
if (h.dc !== 1) return "misconfigured";
var per = h.period_ms == null ? null : Number(h.period_ms);
var dc = h.dc_cycle_us == null ? null : Number(h.dc_cycle_us);
var perOk = per == null || (per >= ECAT_PERIOD_MIN_MS && per <= ECAT_PERIOD_MAX_MS);
var dcMin = Math.round(ECAT_PERIOD_MIN_MS * 1000);
var dcMax = Math.round(ECAT_PERIOD_MAX_MS * 1000);
var dcOk = dc == null || (dc >= dcMin && dc <= dcMax);
if (!perOk || !dcOk) return "misconfigured";
if (per != null && dc != null) {
if (Math.abs(Math.round(per * 1000) - dc) > 1) return "misconfigured";
}
return "ok";
}
/**
* 自动重启的预算。三条都要,缺一条就会从"修楔死"变成"造楔死":
* in-flight —— 同一上下文内不并发(一次 motor/start 最长 30s,足够叠好几记)
* interval —— 最小间隔,时间戳**先写再发**;落 localStorage 才能跨标签页/跨 F5
* (tab-keepalive 下每页 iframe 常驻,上下文不止一个)
* streak —— 连续 N 次没救活就别再试了(深楔死要的是断电,不是第 N+1 次重连)
*/
const LS_AUTO_START_AT = "dcu_ecat_auto_start_at";
export const AUTO_START_MIN_INTERVAL_MS = 20000;
export const AUTO_START_MAX_STREAK = 3;
const autoLoop = { inFlight: false, streak: 0, lastWhy: "" };
/** 谁看到 loop 真的在跑,谁把 streak 清零(不要求是发起重启的那个人)。 */
export function noteEcatLoopUp() {
autoLoop.streak = 0;
autoLoop.lastWhy = "";
}
/** @returns {{inFlight:boolean, streak:number, lastWhy:string}} 供界面/测试读 */
export function autoLoopState() {
return { inFlight: autoLoop.inFlight, streak: autoLoop.streak, lastWhy: autoLoop.lastWhy };
}
/**
* 自动重启 PDO 循环的**唯一**入口。自己重新证明一次(不信调用方的分类),
* 证明是 down 且预算允许,才走 open + motor/start。
*
* @param {(path:string,body?:object,timeoutMs?:number)=>Promise<any>} ecatPost
* @param {string} reason 记进 body,供代理侧统一计数/上限(B4)与人看日志
* @param {{treatOfflineAsDown?:boolean, adapter?:string, fromLocalStorage?:boolean}} [opts]
* @returns {Promise<{acted:boolean, why:string}>}
*/
export async function ensureEcatLoopRepair(ecatPost, reason, opts) {
opts = opts || {};
if (autoLoop.inFlight) return { acted: false, why: "in_flight" };
var h = null;
try {
h = await ecatPost("native/health", {}, 2500);
} catch (_) {
h = null;
}
if (proveEcatLoop(h, opts) === "up") {
noteEcatLoopUp();
return { acted: false, why: "not_down" };
}
if (proveEcatLoop(h, opts) !== "down") return { acted: false, why: "unproven" };
if (autoLoop.streak >= AUTO_START_MAX_STREAK) return { acted: false, why: "capped" };
var now = Date.now(), last = 0;
try {
last = Number(localStorage.getItem(LS_AUTO_START_AT)) || 0;
} catch (_) {}
if (now - last < AUTO_START_MIN_INTERVAL_MS) return { acted: false, why: "interval" };
try {
localStorage.setItem(LS_AUTO_START_AT, String(now)); /* 先占额度再发:并发才不会叠加 */
} catch (_) {}
autoLoop.inFlight = true;
autoLoop.streak++;
try {
await runEcatBootstrap({
ecatPost: ecatPost,
adapter: opts.adapter,
fromLocalStorage: opts.fromLocalStorage !== false,
reason: reason || "auto_loop_repair",
});
autoLoop.lastWhy = "ok";
return { acted: true, why: reason || "auto_loop_repair" };
} catch (e) {
autoLoop.lastWhy = (e && e.message) || "failed";
throw e;
} finally {
autoLoop.inFlight = false;
}
}
/**
* @description 只读健康:bus_open + loop = 后台 PDO 循环在跑。判据与 proveEcatLoop
* 同一处实现(两个"看起来在跑"的定义迟早会漂移,而这里为真时上面那条必须为真)。
*/
export async function isEcatBusHealthy(ecatPost) {
try {
return proveEcatLoop(await ecatPost("native/health", {}, 2500)) === "up";
} catch (_) {
return false;
}
}
/**
* 确保网卡已打开。已连接则复用,不重复 open。
* @param {(path:string,body?:object,timeoutMs?:number)=>Promise<any>} ecatPost
* @param {string} [adapter]
*/
export async function openEcatAdapter(ecatPost, adapter) {
var substr = (adapter && String(adapter).trim()) || readAdapterRaw() || ECAT_DEFAULT_ADAPTER;
var st = await ecatPost("status", {}, 6000);
if (st && st.ok === true) return st;
var r = await ecatPost("open", { substr: substr }, 20000);
if (!r || r.ok !== true) {
var msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "打开网卡失败";
var err = new Error(msg);
err.transient = false;
throw err;
}
return r;
}
/**
* 启动 OP + PDO 循环。force=false 且总线已健康则跳过 start。
* @returns {Promise<{reused:boolean, result?:object}>}
*/
export async function startEcatMotorLoop(ecatPost, opts) {
opts = opts || {};
if (!opts.force && await isEcatBusHealthy(ecatPost)) {
return { reused: true };
}
var body;
if (opts.body) body = opts.body;
else if (opts.fromLocalStorage) {
body = resolveEcatStartBody({ fromLocalStorage: true, period_ms: readStoredEcatPeriod() });
} else {
body = resolveEcatStartBody({ period_ms: opts.period_ms, spin: opts.spin });
}
var r = await ecatPost("motor/start", Object.assign({}, body,
/* reason 随 body 走:代理侧据此对"自动修复类"重启统一节流上限(B4),
* 页面重写一版也不会漏 —— 用户自己点的那一记永不拦。 */
opts.reason ? { reason: String(opts.reason) } : {}), 30000);
if (r && r.ok === false) {
throw new Error((r.log && r.log.join(" / ")) || "motor/start 失败");
}
return { reused: false, result: r || {}, body: body };
}
/**
* 完整 bootstrap:health → open → motor/start。
* @param {{
* ecatPost: (path:string,body?:object,timeoutMs?:number)=>Promise<any>,
* adapter?: string, period_ms?: number, spin?: boolean|null, force?: boolean,
* fromLocalStorage?: boolean, reason?: string,
* }} opts
* @returns {Promise<{ok:true, reused:boolean, result?:object}>}
*/
export async function runEcatBootstrap(opts) {
var ecatPost = opts.ecatPost;
if (await isEcatBusHealthy(ecatPost) && !opts.force) {
return { ok: true, reused: true };
}
await openEcatAdapter(ecatPost, opts.adapter);
var st = await startEcatMotorLoop(ecatPost, {
force: true,
period_ms: opts.period_ms,
spin: opts.spin,
fromLocalStorage: opts.fromLocalStorage,
body: opts.body,
reason: opts.reason,
});
return { ok: true, reused: false, result: st.result };
}
/**
* DC/周期自愈:被**证明**配错(free-run / period 越界 / period 与 DC 不同值)时
* 按当前合法 period 把 DC 对齐回去。判据在 proveEcatDc 里(唯一实现):
* 证明不了 = 不动总线。这条规则的存在理由就是 healthCheck 那份内联副本 ——
* 它把"没有 dc 字段"当成"DC 配错",于是一篇错误回包就打出一记 loop_stop 的
* motor/start(记忆 ecat-bus-exclusive-route-restart-trips-watchdog)。
* 修复目标是「period 与 DC 同值」,不是「一律拉回 0.25」。
* @param {(path:string,body?:object,timeoutMs?:number)=>Promise<any>} ecatPost
* @param {object} [knownHealth] 调用方已经读到的**原始** native/health 回包(省一次往返,
* 判据仍只走 proveEcatDc 这一处)。传错形状会被判成 unknown —— 也就是不动总线。
* @returns {Promise<object|null>} null=无需修复或无法证明
*/
export async function repairEcatDc(ecatPost, knownHealth) {
var h = knownHealth === undefined ? null : knownHealth;
if (h === null) {
try {
h = await ecatPost("native/health", {}, 2000);
} catch (_) {
h = null;
}
}
if (proveEcatDc(h) !== "misconfigured") return null;
/* native 正在全量重连(close→open→OP,秒级)时再插一记 motor/start 是自相残杀:
* 两条恢复路各自 loop_stop,最后谁都不知道总线是谁停的。 */
if (h && h.recovering) return null;
var per = readStoredEcatPeriod();
if (h && h.period_ms != null) {
var hp = Number(h.period_ms);
if (hp >= ECAT_PERIOD_MIN_MS && hp <= ECAT_PERIOD_MAX_MS) per = hp;
}
var body = resolveEcatStartBody({ period_ms: per });
body.reason = "auto_dc_repair";
return ecatPost("motor/start", body, 30000);
}
/** @description 读当前网卡子串(localStorage → 默认 I210)。 */
export function getEcatAdapterOrDefault() {
return readAdapterRaw();
}