同一症状(点一次发送→很久才动/不动)至少有五条路径能到达,上一轮只加固了 native 检测层那一条的一半。本 commit 关掉两条由我们自己的前端反复按 loop_stop 的成因——不需要硬件故障就能把健康总线掐停:motor/start 内部先 loop_stop,停手 >1000ms 从站过程数据看门狗就踢出 OP,反复这样会把 SSC 邮箱楔到只能断电。 1) healthCheck 违反自己的契约。文件注释写着「只读…绝不 motor/start、不动总线」, 代码却是内联的 dc !== 1 || dcs !== 250 → motor/start。这把「回包没有 dc 字段」 当成「DC 配错」,于是一篇错误回包/一份旧 exe 就打出一记 loop_stop,而这条路径 只在 probe() 超时后走(那一刻已经有一记 loop_stop 在飞)。 git 史:断言383535b引入即红 →2304ed6改走 ecat-bus.repairEcatDc(含「缺字段 不动总线」守卫)转绿 →7ea80f0前端回滚删掉那次调用,又红。 2) OTA 负载页以「读数不新鲜」为理由无条件 motor/start。固件 <1.5.61(段5 恒 0)、 GD32 没跑 APP、首个 1s 窗口没滚完都会读数不新鲜而循环完全健康——一个跟电机无关 的页面于是周期性掐停总线。现在先证明再动手;native 完全没起仍算「该拉起」, 刷写体验不变。 做法:判定与预算只放 ecat-bus.js—— proveEcatLoop(h) 'up'|'down'|'unknown':三态是重点,证明不了就不许自动动手 proveEcatDc(h) 同上,把「缺字段=证明不了」收编成唯一实现 ensureEcatLoopRepair() in-flight 锁 + 最小间隔 20s(时间戳先写后发,落 localStorage 才跨标签页/跨 F5)+ 连续 3 次封顶;reason 随 body 走,供代理侧统一上限 repairEcatDc 新增「native 正在全量重连时不动总线」;isEcatBusHealthy 与 proveEcatLoop 共用同一处判定(两个「看起来在跑」迟早漂移)。transport-ecat 的 readHealth 附带原始 回包 raw,证明函数与界面读同一份,不再抄一遍字段名。 测试:test_ecat_autostart.mjs 26→57 项。stub 现在能喂任意 health 形状并按 reason 计数,新增 [2c] 反空断言(DC 确实配错时必须恰又一次且带 reason)、[2d] 三态矩阵、 [2e] OTA 决策、[2f] 预算(间隔/封顶/复位后放行)。另加 scripts/check_fe_guard_mutation.py: 把实现逐条退回旧形状看断言是否真变红(全绿=那条断言是自证的)——M1..M6 实测全部变红, 还原后基线仍 57/0。配套门槛见 tools/ecat/check_guard_regression.py 的 S6/S7。 未做(另计):loop=0 的三重闩锁(transport 的 started 位、transient 正则、device-link 的 e.loop=loadValid||op 假位——native /api/status 的 slave.state 是缓存,循环停了仍报 8) 与 UI 对 loop/guard 的可见性;以及 loop_stop 不清 g_cmd_q 造成的「重启那刻陈旧命令一起灌出去」。
320 lines
13 KiB
JavaScript
320 lines
13 KiB
JavaScript
/**
|
||
* 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 楔死风险)
|
||
* - **自动化只能基于证明,人才能基于意图**:任何非用户直接触发的 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):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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 「后台 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 旧规则收编于此)。
|
||
* @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 && Number(h.dc_cycle_us) === ECAT_DC_CYCLE_US &&
|
||
(h.period_ms == null || Math.abs(Number(h.period_ms) - ECAT_DEFAULT_PERIOD_MS) < 0.001)) {
|
||
return "ok";
|
||
}
|
||
return "misconfigured";
|
||
}
|
||
|
||
/**
|
||
* 自动重启的预算。三条都要,缺一条就会从"修楔死"变成"造楔死":
|
||
* 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 或 ≥1ms)时拉回 250µs。
|
||
* 判据在 proveEcatDc 里(唯一实现):证明不了 = 不动总线。这条规则的存在理由就是
|
||
* healthCheck 那份内联副本 —— 它把"没有 dc 字段"当成"DC 配错",于是一篇错误回包
|
||
* 就打出一记 loop_stop 的 motor/start(记忆 ecat-bus-exclusive-route-restart-trips-watchdog)。
|
||
* @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.recovering) return null;
|
||
return ecatPost("motor/start", {
|
||
period_ms: ECAT_DEFAULT_PERIOD_MS,
|
||
dc_cycle_us: ECAT_DC_CYCLE_US,
|
||
dc_shift_us: 0,
|
||
reason: "auto_dc_repair",
|
||
}, 30000);
|
||
}
|
||
|
||
/** @description 读当前网卡子串(localStorage → 默认 I210)。 */
|
||
export function getEcatAdapterOrDefault() {
|
||
return readAdapterRaw();
|
||
}
|