Files
DCU_DesktopController/js/common/ecat-bus.js
T
cbb 514070b36d fix(ui): 「谁可以动总线」收到一处判定;healthCheck 与 OTA 页不再自己 loop_stop
同一症状(点一次发送→很久才动/不动)至少有五条路径能到达,上一轮只加固了
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 造成的「重启那刻陈旧命令一起灌出去」。
2026-09-22 00:30:33 +08:00

320 lines
13 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 循环
*
* 红线:
* - 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();
}