/** * 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} 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} 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, * 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} ecatPost * @param {object} [knownHealth] 调用方已经读到的**原始** native/health 回包(省一次往返, * 判据仍只走 proveEcatDc 这一处)。传错形状会被判成 unknown —— 也就是不动总线。 * @returns {Promise} 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(); }