/** * ecat-bus.js — EtherCAT 总线 bootstrap / open / motor-start 唯一实现 * * 设备链路(device-link 外壳 Owner)与电机传输(transport-ecat)都调用本模块, * 策略只保留一份: * health →(未就绪)status →(未开)open 网卡 → motor/start 进 OP + 起 PDO 循环 * * 红线: * - DC 钉死 250µs(period_ms 默认/钳制 0.25,dc_cycle_us = max(250, round(ms*1000))) * - 幂等:native/health 报 bus_open+loop 时**复用**,不再 motor/start * (start 会先 loop_stop,热路径假超时 / SSC 楔死风险) * - 网卡默认常量 ECAT_DEFAULT_ADAPTER 只在此定义 */ "use strict"; /** 网卡匹配子串默认值(代码/页面/测试的唯一来源;文档漂移以此为准)。 */ export const ECAT_DEFAULT_ADAPTER = "I210"; /** 默认 PDO 周期(ms):1ms+DC 会让发送出口回到 ~1s,必须 250µs 档。 */ export const ECAT_DEFAULT_PERIOD_MS = 0.25; /** DC SYNC0 目标周期(µs)。 */ export const ECAT_DC_CYCLE_US = 250; 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.5ms 且默认 0.25,DC 钉 250µs。 * @param {{period_ms?:number, spin?:boolean|null, fromLocalStorage?:boolean}} [opts] */ export function resolveEcatStartBody(opts) { opts = opts || {}; var body = {}; var per = Number(opts.period_ms); if (!opts.fromLocalStorage && opts.period_ms == null) { per = ECAT_DEFAULT_PERIOD_MS; } else if (!(per > 0) || per > 0.5) { per = ECAT_DEFAULT_PERIOD_MS; } body.period_ms = per; body.dc_cycle_us = Math.max(ECAT_DC_CYCLE_US, Math.round(per * 1000)); 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 !== 1) return v; return ECAT_DEFAULT_PERIOD_MS; } catch (_) { return ECAT_DEFAULT_PERIOD_MS; } } /** @description 只读健康:bus_open + loop = 后台 PDO 循环在跑。 */ export async function isEcatBusHealthy(ecatPost) { try { var h = await ecatPost("native/health", {}, 2500); return !!(h && h.ok && h.bus_open && h.loop); } 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", body, 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, * }} 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, }); return { ok: true, reused: false, result: st.result }; } /** * DC/周期自愈:被配错(free-run 或 ≥1ms)时拉回 250µs。 * @returns {Promise} null=无需修复 */ export async function repairEcatDc(ecatPost) { var h = await ecatPost("native/health", {}, 2000).catch(function () { return null; }); if (!h || !h.ok) return null; var dcs = Number(h.dc_cycle_us); var per = Number(h.period_ms); if (h.dc === 1 && dcs === ECAT_DC_CYCLE_US && (h.period_ms == null || Math.abs(per - ECAT_DEFAULT_PERIOD_MS) < 0.001)) { return null; } return ecatPost("motor/start", { period_ms: ECAT_DEFAULT_PERIOD_MS, dc_cycle_us: ECAT_DC_CYCLE_US, dc_shift_us: 0, }, 30000); } /** @description 读当前网卡子串(localStorage → 默认 I210)。 */ export function getEcatAdapterOrDefault() { return readAdapterRaw(); }