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 造成的「重启那刻陈旧命令一起灌出去」。
This commit is contained in:
cbb
2026-09-22 00:30:33 +08:00
parent 83a035c6d6
commit 514070b36d
5 changed files with 501 additions and 97 deletions
+150 -18
View File
@@ -9,6 +9,12 @@
* - 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";
@@ -81,11 +87,125 @@ export function readStoredEcatPeriod() {
}
}
/** @description 只读健康:bus_open + loop = 后台 PDO 循环在跑。 */
/**
* 「后台 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 {
var h = await ecatPost("native/health", {}, 2500);
return !!(h && h.ok && h.bus_open && h.loop);
return proveEcatLoop(await ecatPost("native/health", {}, 2500)) === "up";
} catch (_) {
return false;
}
@@ -126,7 +246,10 @@ export async function startEcatMotorLoop(ecatPost, opts) {
} else {
body = resolveEcatStartBody({ period_ms: opts.period_ms, spin: opts.spin });
}
var r = await ecatPost("motor/start", body, 30000);
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 失败");
}
@@ -138,7 +261,7 @@ export async function startEcatMotorLoop(ecatPost, opts) {
* @param {{
* ecatPost: (path:string,body?:object,timeoutMs?:number)=>Promise<any>,
* adapter?: string, period_ms?: number, spin?: boolean|null, force?: boolean,
* fromLocalStorage?: boolean,
* fromLocalStorage?: boolean, reason?: string,
* }} opts
* @returns {Promise<{ok:true, reused:boolean, result?:object}>}
*/
@@ -154,30 +277,39 @@ export async function runEcatBootstrap(opts) {
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。
* health 未回报 dc/period 字段时**不动总线**(healthCheck 契约:只读)。
* @returns {Promise<object|null>} null=无需修复
* 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) {
var h = await ecatPost("native/health", {}, 2000).catch(function () { return null; });
if (!h || !h.ok) return null;
/* 缺关键字段 = 无法证明配错,禁止 motor/start(healthCheck 要求不碰 start) */
if (h.dc == null && h.period_ms == null && h.dc_cycle_us == null) 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;
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);
}
+16 -10
View File
@@ -42,6 +42,9 @@
* 「ECAT 槽判活阈值」一节,纯函数以便脱离 DOM 自测。 */
import { staleLimitMs } from "./refresh-rate.js";
import { deviceLink } from "../common/device-link.js";
/* 总线"能不能动"的判据只有一份,放在 ecat-bus.js(与 device-link 同源、同样不带
* ?v= ⇒ 浏览器按同一 URL 去重,Owner 与本模块共用同一个模块实例与同一份预算)。 */
import { proveEcatLoop, repairEcatDc, noteEcatLoopUp } from "../common/ecat-bus.js";
import {
desktopEcatPost, desktopBridgeLabel, getDesktopEcatApi,
desktopEcatSendRaw, hasDesktopFastSend,
@@ -334,14 +337,19 @@ export function createEcatTransport(ctx) {
op: !!h.op,
loop: !!h.loop,
cycles: Number(h.cycles) || 0,
/* 原始回包:证明函数与界面要读 dc/period_ms/guard/recovering/pm_hold 这些
* 只存在于 native 侧的字段。再抄一遍字段名 = 迟早与 native 漂移,
* 而漂移的方向通常是"页面以为一切正常"。 */
raw: h,
};
}
/** 总线已开 + PDO 循环在跑 → 视为链路就绪,跳过 motor/start。 */
/** 总线已开 + PDO 循环在跑 → 视为链路就绪,跳过 motor/start。
* 判据是 ecat-bus.proveEcatLoop 的 'up'(三态里只有 up 算健康)。 */
async function isBusHealthy() {
try {
const h = await readHealth();
return !!(h && h.busOpen && h.loop);
return proveEcatLoop(h && h.raw) === "up";
} catch (_) {
return false;
}
@@ -614,20 +622,18 @@ export function createEcatTransport(ctx) {
const h = await readHealth();
if (!h || !h.busOpen) throw new Error("EtherCAT 服务未打开网卡");
if (!h.loop) throw new Error("PDO 循环未运行");
/* 自愈:DC/周期被配错(free-run 或 ≥1ms)时发送出口会到秒级 —— 拉回 250µs */
/* DC 自愈只能走 ecat-bus.repairEcatDc:它"证明不了配错就不动总线"。
* 以前这里是内联的 dc !== 1 || dcs !== 250 —— 把"回包没有 dc 字段"当成
* "DC 配错",于是一篇错误回包/一份旧 exe 就打出一记 loop_stop 的
* motor/start,而这条路径恰好只在 probe() 超时(已有一记 loop_stop 在飞)后走。 */
try {
const raw = await api("native/health", {}, 2000);
const dc = raw && raw.dc;
const dcs = raw && raw.dc_cycle_us;
const per = raw && raw.period_ms;
if (dc !== 1 || dcs !== 250 || (per != null && Math.abs(per - 0.25) > 0.001)) {
await api("motor/start", { period_ms: 0.25, dc_cycle_us: 250, dc_shift_us: 0 }, 30000);
}
await repairEcatDc(api, h.raw);
} catch (_) { /* 自愈失败仍按 health 结果返回 */ }
const st = await api("status", {}, 2500);
if (!st || st.ok !== true) throw new Error("从站状态不可读");
if (st && typeof st === "object") st.__op = !!h.op;
started = true;
noteEcatLoopUp(); /* 走到这里 health 已证明 loop=1:把自动重启预算交还给下一次故障 */
try {
const snap = await api("motor/state", {}, 2500);
if (snap && snap.ok === true) syncFromSnapshot(snap);
+70 -57
View File
@@ -10,12 +10,17 @@
* 与 /system/status 轮询完全独立:ESP32 离线不影响本显示,反之亦然;
* 失败只静默退避,绝不计入 ESP32 的连接失败计数。
*
* **自动拉起**(2026-09):读到 invalid/过期时,代替用户走完
* **自动拉起**(2026-09-21 起改为"先证明再动"):只有证明"后台 PDO 循环确实停了"
* 才代替用户走完
* 服务→网卡→OP→PDO 循环 四级自愈(与 motor/transport-ecat.js 同一套 API),
* 不必再单独打开电机页选 EtherCAT。避让:
* 不必再单独打开电机页选 EtherCAT。为什么必须改:'读数不新鲜' 与 '循环停了' 不是一回事
* —— 固件 <1.5.61(段 5 恒 0)、app 没跑、首个 1s 窗口没滚完,都会读数不新鲜而循环
* 完全健康;旧实现按读数不新鲜就 motor/start,而 motor/start 先 loop_stop,于是这个
* 跟电机无关的页面会周期性掐停健康总线(停手 >1s 看门狗踢出 OP,反复会把邮箱楔到只能断电)。
* 避让:
* - ECAT 刷写中(window.bootEcatBusy)或其它 OTA 在跑时**绝不动总线**
* —— open 会 loop_stop、motor/start 会抢 OP,会把正在灌写的会话掐死;
* - 冷却 8s,避免无适配器/无从站时每秒刷 open;
* - 冷却 8s(本页)+ 自动重启全局预算:最小间隔 20s、连续 3 次没救活就封顶(ecat-bus);
* - 首次成功只打一条日志,之后静默。
* 首页需持有 app 租约(index.html 挂 app-lease.js),否则服务端在
* 租约归零宽限期后会把刚拉起的循环又停掉。
@@ -31,7 +36,9 @@
* 请求统一走 ConnectionHub(device-link.ecatPost),与 Owner 健康轮询同源。
*/
import { deviceLink } from '../common/device-link.js';
import { ECAT_DEFAULT_ADAPTER } from '../common/ecat-bus.js';
import {
proveEcatLoop, ensureEcatLoopRepair, noteEcatLoopUp,
} from '../common/ecat-bus.js';
var POLL_MS = 1000; /* 正常轮询节拍(负载窗口 1s 一滚,再快没意义) */
@@ -57,8 +64,9 @@ function setTitle(node, title) {
}
var TIP_BASE = 'DCU_APP CPU 负载率:中断+非延迟处理(任务派发/OTA中继)占墙钟比,不含常开轮询与空转;EtherCAT 输入 PDO 段5,1s 窗口';
var TIP_OFF = TIP_BASE + ';当前不可用:外壳/本页会自动 open+motor/start;'
+ '若持续为 --,检查网卡/总线供电,或手动打开电机控制页选 EtherCAT。'
var TIP_OFF = TIP_BASE + ';当前不可用:只有证明 PDO 循环确实停了,本页才会自动 open+motor/start;'
+ '循环在跑而这里仍是 --,通常是固件 <1.5.61(段5 恒 0)、GD32 没跑 APP,'
+ '或刚拉起还没滚完首个 1s 窗口 —— 这些都不会去重启总线。'
+ 'ECAT 刷写期间会暂停自动拉起。';
/** @description 把 /api/app/load 的 JSON 渲染到 #mGd32Load。返回值是否有效(驱动退避)。 */
@@ -98,15 +106,6 @@ function ecatPost(path, body, timeoutMs) {
}).then(function (r) { return r.json(); });
}
function adapterSubstr() {
try {
var s = localStorage.getItem('dcu_ecat_adapter');
if (s && s.trim()) return s.trim();
} catch (_) {}
return ECAT_DEFAULT_ADAPTER;
}
/** 刷写/上传进行中:绝不动总线(open=loop_stop,start=抢 OP)。 */
function busBusy() {
return !!window.bootEcatBusy || !!window.isUploading
@@ -114,54 +113,64 @@ function busBusy() {
}
/**
* 确保网卡已开 + OP + 后台 PDO 循环在跑。
* 与 motor/transport-ecat.js 的 ensureOpen/ensureStarted 同一套 API,
* 只做布尔成败,不引入 transient/慢请求表(本页没有那套基础设施)。
* 纯判据:给定 native/health 的回包,本页该不该动总线。单独导出是为了能被离线判死
* (测试不必装 DOM)。判据本身仍只有 proveEcatLoop 一处实现。
* @returns {"repair"|"idle-up"|"unproven"}
*/
async function ensurePdoCycle() {
var st = null;
try {
st = await ecatPost('status', {}, 6000);
} catch (_) {
/* 后端未起:下面 open 会触发代理 _ecat_autostart */
}
if (!st || st.ok !== true) {
var o = await ecatPost('open', { substr: adapterSubstr() }, 25000);
if (!o || o.ok !== true) return false;
}
/* 周期/自旋与电机页共用 localStorage,用户调过的档位不被本页覆盖。 */
var body = {};
try {
var v = parseFloat(localStorage.getItem('dcu_ecat_period_ms'));
if (v > 0) body.period_ms = v;
var sp = localStorage.getItem('dcu_ecat_spin');
if (sp === '0' || sp === '1') body.spin = (sp === '1');
} catch (_) {}
/* motor/start 内部未进 OP 时自己走 PREOP→SAFEOP→OP,最坏约 24s */
var sr = await ecatPost('motor/start', body, 30000);
return !!(sr && sr.ok === true);
export function decidePdoCycle(h) {
/* treatOfflineAsDown:连不上/没开网卡时不存在"被我们停掉的健康循环",拉起是安全的
* —— 这正是本页存在的意义(不必先开电机页也能刷写)。只有本页传这个旗标。 */
var pr = proveEcatLoop(h, { treatOfflineAsDown: true });
if (pr === "down") return "repair";
if (pr === "up") return "idle-up";
return "unproven";
}
/** 读数无效时尝试自动拉起。冷却 + 刷写避让。返回是否本次调用成功启动。 */
/**
* 需要时才把总线拉起来:先证明循环停了,再走 网卡 open → motor/start。
* 动作本身交给 ecat-bus.ensureEcatLoopRepair —— 它与电机页、Owner 共用同一份预算
* (in-flight 锁 / 最小间隔 20s / 连续 3 次封顶),否则三个页面各自节流就等于三倍频率。
* @returns {Promise<string>} 'idle-up' | 'started' | 'unproven' | 'refused:<why>'
*/
async function ensurePdoCycle() {
var h = null;
try {
h = await ecatPost('native/health', {}, 3000);
} catch (_) {
h = null; /* 代理 503 / native 未起:proveEcatLoop 会按 treatOfflineAsDown 归类 */
}
if (decidePdoCycle(h) !== 'repair') {
if (proveEcatLoop(h) === 'up') noteEcatLoopUp();
return decidePdoCycle(h);
}
var r = await ensureEcatLoopRepair(ecatPost, 'auto_loop_repair', {
treatOfflineAsDown: true,
/* 周期/自旋与电机页共用 localStorage,用户调过的档位不被本页覆盖。 */
fromLocalStorage: true,
});
return r.acted ? 'started' : 'refused:' + r.why;
}
/** 读数无效时按需拉起。冷却 + 刷写避让。返回本次的判定(字符串协议见 ensurePdoCycle)。 */
async function tryAutoStart() {
if (busBusy()) return false;
if (busBusy()) return 'busy';
var now = Date.now();
if (now - startAttemptAt < START_COOLDOWN_MS) return false;
if (now - startAttemptAt < START_COOLDOWN_MS) return 'cooldown';
startAttemptAt = now;
try {
var ok = await ensurePdoCycle();
if (ok) {
if (!startLogged) {
startLogged = true;
if (window.addLog) {
window.addLog('I', 'ECAT',
'已自动拉起 EtherCAT PDO 循环(GD32 负载将在首个 1s 窗口后显示)');
}
var act = await ensurePdoCycle();
if (act === 'started' && !startLogged) {
startLogged = true;
if (window.addLog) {
window.addLog('I', 'ECAT',
'已自动拉起 EtherCAT PDO 循环(GD32 负载将在首个 1s 窗口后显示)');
}
return true;
}
} catch (_) { /* 代理/后端离线、open/start 失败:走退避 */ }
return false;
return act;
} catch (_) {
/* 代理/后端离线、open/start 失败:走退避 */
return 'failed';
}
}
async function tick() {
@@ -174,12 +183,16 @@ async function tick() {
if (ok) {
failCount = 0;
} else {
var started = false;
try { started = await tryAutoStart(); } catch (_) {}
if (started) {
var act = 'skipped';
try { act = await tryAutoStart(); } catch (_) {}
if (act === 'started') {
/* 刚 start 成功时 seq 仍可能是 0(首个 1s 窗口未滚完),
* 温和计数:保持 1s 节拍等窗口,不要立刻 5s/30s 退避。 */
failCount = Math.min(failCount, 1);
} else if (act === 'idle-up') {
/* 循环确实在跑,只是读数不新鲜(固件 <1.5.61 段5 恒 0 / app 没跑 / 窗口没滚完)。
* 这不是"该重启",继续敲门的唯一后果是白读几轮 —— 退到 30s 慢档。 */
failCount = Math.max(failCount, 7);
} else {
failCount++;
}