Files
DCU_DesktopController/js/motor/transport-ecat.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

925 lines
42 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.
/**
* transport-ecat.js — 电机控制台的 EtherCAT 传输实现
*
* 后端是 tools/ecat 的 ecat_native.exe(C/SOEM 守护进程,另一个 git 仓库)。
* 控制面双路径(2026-09-23):
* - 桌面 APP(esp32_app.vbs / --window):window.top.pywebview.api.ecat_api
* → GUI 进程裸 socket 直连 native(desktop-bridge.js),不经浏览器 HTTP;
* - 网页/浏览器:同源 /ecat/api/* 反向代理(见 esp32_server.py)。
* 无论哪条路径,真正上总线的 EtherCAT PDO 都由 native 后台循环维持。
*
* **全自动接入**:切到本链路不需要任何手工前置步骤。四级按需自愈:
* 1. 服务没起 → 代理侧 _ecat_autostart() 拉起 tools/ecat\build_native\ecat_native.exe(已有实例只复用)
* 2. 网卡没开 → ensureOpen() 调 /api/open(网卡子串存 localStorage dcu_ecat_adapter)
* 3. 没进 OP → /api/motor/start 内部自己走 PREOP→SAFEOP→OP
* 4. 循环没跑 → /api/motor/start 起后台 PDO 循环(喂 1000ms 过程数据看门狗)
* 断线后 main.js 的探测器每 2s 重来一遍,所以中途拔网线再插也会自己恢复。
* 探测自身是**幂等**的:native/health 显示 bus_open+loop 时直接复用,
* 不再打 motor/start(那条路由会先 loop_stop,热路径假超时 / 楔死风险)。
* probe 超时后另有 healthCheck 只读复核,避免「实际已连上却报未连」。
*
* 本模块的**唯一职责**是把 EtherCAT 过程数据的形状翻译成 WiFi 传输那套契约,
* 好让 main.js 的解码器 / 节点列表 / STATUS 面板 / 波形 / 命令序列全部原样复用。
* 三处形状差异必须在这里补平,否则上层就得为两种传输各写一遍:
*
* 1. **config_data**:WiFi 的 /motor/state 每个电机自带 24B CONFIG 回报;
* EtherCAT 状态窗口只有 32B STATUS,CONFIG(0x000N0101) 是当普通帧从
* 批量接收区回来的。故这里累积 configs[node],回填到 motors[].config_data。
* 2. **版本**:WiFi 由 GD32 解析后经 0xC6 缓存到 /motor/version;EtherCAT
* 没有这个缓存,版本应答(0x000N00FF)同样从批量接收区来,这里自建缓存并
* 维护 seq,让 main.js 那套"按 seq 增长判新应答"的逻辑不用改。
* 3. **节点 ID 应答**:WiFi 走 SPI 请求-应答,帧结果里直接带 recv;
* EtherCAT 是异步的,应答稍后才到,这里存起来供上层取。
*
* 另外两处**不能假装有**的东西,宁可报 null 让 UI 显示 "--":
* - 每通道 TEC/REC/err:过程数据里根本没有(bridge 只有全局计数),
* 编 0 会让"总线正常"和"没这个数据"看起来一样,违背监控 UI 的约定。
* - OTA:EtherCAT 侧没有固件上传 + GD32 relay 链路,supportsOta=false。
*/
"use strict";
/* 判活阈值纯逻辑(随实测 seq 间隔自适应)住在 refresh-rate.js 的
* 「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,
} from "../common/desktop-bridge.js?v=20260923i";
/** 单个 PDO 周期回传的状态槽数。
* 固件 1.6.21 起段 2 密排整轮:一拍带齐总线电机(最多 9),空槽不占位。
* 旧 1.6.15 滑窗为 5 槽;后端 decode_input 按 magic@35 自适应,本常量仅
* 影响"扫完全表"的展示口径,不再决定能否拿齐电机。 */
const MOT_SLOTS_PER_PDO = 9;
/** 状态槽总数 = 9 节点 × 3 通道(ecat_native.c 的 MOTOR_SLOT_COUNT)。
* 1.6.21 密排后每节点只报最新一条,经 (node-1)*3+ch 落回本表。 */
const MOTOR_SLOT_COUNT = 27;
/** 扫完全表所需周期数。1.6.21 密排下恒 1(一拍整轮);旧滑窗为 ⌈27/5⌉=6。
* 显示"数据多新"请用快照里的 slot_period_ms。 */
const MOTOR_SCAN_CYCLES = 1;
/**
* 槽 seq 判活阈值:**不写死** —— 随该槽实测 seq 变化间隔自适应
* clamp(3×EWMA, 2s, 30s),见 refresh-rate.js 的 staleLimitMs()。
* 为什么需要判活:后端的快照把见过的槽**永久累积**,电机拔了也不会消失,
* 只能靠 seq 是否还在涨来判活。为什么必须自适应:电机 CAN 回报率可变,
* 回报周期调到秒级后,固定 2s 会在两次正常回报之间把正常电机判离线
* (LED 闪烁、波形画虚线、面板切离线覆盖层);下限 2s 仍兜底快周期
* (要大于"扫完全表 + 一点抖动",1.6.15 滑窗时代 period 10ms × 6 周期 = 60ms)。
*/
/** 位掩码 → 逻辑处理器号列表。JS 移位只有 32 位,所以用除法逐位取。 */
function pcoreList(mask) {
const out = [];
let m = mask >>> 0;
for (let b = 0; b < 32 && m; b++, m >>>= 1) if (m & 1) out.push(b);
/* 高 32 位(>31 的核,本机 P 核在 0..15,但大核数机器会越过 31) */
let hi = Math.floor(mask / 4294967296);
for (let b = 0; b < 32 && hi; b++, hi >>>= 1) if (hi & 1) out.push(b + 32);
return out.sort((a, b) => a - b);
}
/** 连续核号压成区间:[0..15] → "0-15"。状态栏只有一行,
* 核号全列出来会把从站名挤掉,而区间信息量一样。 */
function pcoreRanges(list) {
if (!list.length) return "";
const parts = [];
let s = list[0], p = list[0];
for (let i = 1; i <= list.length; i++) {
if (i < list.length && list[i] === p + 1) { p = list[i]; continue; }
parts.push(s === p ? String(s) : `${s}-${p}`);
if (i < list.length) { s = p = list[i]; }
}
return parts.join(",");
}
/** 周期显示:<1ms 用 µs("250µs"),否则 ms("0.25ms" 这种写法没人读)。
* 与后端 _fmt_period 保持一致,两边显示口径不能分叉。 */
function fmtPeriod(ms) {
if (!(ms > 0)) return "";
return ms < 1 ? `${Math.round(ms * 1000)}µs` : `${ms}ms`;
}
/** @description 创建 EtherCAT 传输实例。 */
export function createEcatTransport(ctx) {
const { recordSlow, isTransient } = ctx;
/* 网卡匹配子串:统一从 ConnectionHub 读写(dcu_ecat_adapter),与 OTA/lab 页共用。 */
let adapterSubstr = deviceLink.getEcatAdapter();
/* ── 传输内部累积状态 ──
* 这些都是"WiFi 侧由固件/GD32 代为缓存、EtherCAT 侧必须前端自己攒"的东西。 */
const configs = new Map(); // node -> 24B CONFIG hex(0x000N0101)
const versions = new Map(); // node -> {node, seq, version, at} 供 fetchVersionNode
const nodeIdReports = new Map();// node -> 应答 hex(0x000N0400/0401)
/* xrx 变更指纹:段3 只有「最近一帧」,内容不变时不要重复 dispatch,
* 否则每拍都把旧版本 seq+1,查询判定会失真。 */
let lastXrxKey = "";
const seqSeen = new Map(); // "ch:node" -> {seq, at, rx, iv} 判活 + 计更新次数;iv=实测变化间隔 EWMA(ms)
let periodMs = 0; // 后台 PDO 循环周期(由 /api/motor/start 回报)
/* 单个电机状态的真实刷新周期(ms,来自快照的 slot_period_ms)。
* 与 periodMs 的区别是要点:PDO 周期是"总线跑多快",而某个电机的状态要等
* 窗口轮到它才更新。后端的窗口自适应会把窗口收缩到只盖有主的槽,
* 稳态下两者相等;但开机首圈、或多台电机分散落在多个窗口时,
* slotPeriodMs 会是 periodMs 的整数倍。状态栏显示这个才不谎报"数据多新"。 */
let slotPeriodMs = 0;
/* 节拍源是否为自旋(来自 motor/start 回报与快照)。
* 必须显示出来:自旋能把 PDO 周期跑准(实测抖动 0.000ms),代价是**吃满一个
* CPU 核**。用户把刷新调到 1ms 档时默认就会自旋,不写清楚的话 CPU 占用
* 看起来像凭空冒出来的。可用 dcu_ecat_spin 显式覆盖("0"=强制内核睡眠)。 */
let spinOn = false;
/* 自旋线程被钉到的逻辑处理器位掩码(快照 pcore_mask)。0 = 没钉(对称 CPU
* 或探测失败)。把落在哪些核显示出来,用户才知道自旋占的是哪些核。 */
let pcoreMask = 0;
const LS_SPIN = "dcu_ecat_spin";
/* 保存的 PDO 周期(ms,允许亚毫秒 float,如 0.25=4kHz)。用户把周期调到
* 内核级档位后存这儿,后端重启、循环重开时原样带回 —— 用户要的
* "后续随时可用"不该每次手敲一遍。恢复默认 = 删掉这个键,跟随后端默认。 */
const LS_PERIOD = "dcu_ecat_period_ms";
/* LS_PERIOD 的一次性迁移标记:Python 时代默认 4ms 曾被存进来(检查脚本
* browser_check_period.mjs 收尾就写 "4"),native 后端默认已改小,
* 存量 "4" 会在每次 ensureStarted 把总线压回旧默认 4ms。只删一次,之后
* 再设 4 视为用户显式选择,原样保留。 */
const LS_PERIOD_MIG = "dcu_ecat_period_mig";
let started = false; // 后台循环是否已启动
let lastSnapshot = null; // 最近一次快照,供 fetchFeedback 复用(不重复打后端)
/**
* POST 到 EtherCAT 后端。双路径:
* 同源 HTTP /ecat/api/*(esp32_server 服务子进程代理)—— 热路径优先;
* APP 桥 window.top.pywebview.api.ecat_api —— 低频控制面,或 HTTP 失败时兜底。
*
* 为什么热路径(motor/state、motor/rx)不再优先走桥:pywebview WebView2
* 每次 JS→Python 往返实测把本机 ~0.9ms 的 native 快照抬到约 20ms/拍,
* 6ms 刷新档被按在 ~50Hz;HTTP 代理 state+rx 串行 p50≈2.9ms(≈350Hz)。
* 桥仍保留给 send/status/probe 等低频路由,以及页面不在代理源上时的兜底。
*
* 失败抛 Error,附 transient,与 WiFi 传输的错误契约一致。
*/
/* 热路径:界面每拍轮询的路由。这些走 HTTP,不进 pywebview 桥。 */
const HOT_HTTP_PATHS = new Set(["motor/state", "motor/rx"]);
/** drainRx 最小间隔:6ms 档下每拍都 drain 会把请求量翻倍;CONFIG/版本
* 应答本身不按 166Hz 到达,20ms 足够跟上且显著降低代理/桥压力。 */
const DRAIN_MIN_MS = 20;
let lastDrainAt = 0;
async function callBridge(tag, body, timeoutMs) {
const t0 = performance.now();
const json = await desktopEcatPost(tag, body || {}, timeoutMs || 4000);
recordSlow("bridge:/" + tag, performance.now() - t0);
return json;
}
async function callHttp(tag, body, timeoutMs) {
const t0 = performance.now();
const url = "/ecat/api/" + tag;
let resp;
try {
resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body || {}),
signal: AbortSignal.timeout(timeoutMs || 4000),
cache: "no-store",
});
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
err.transient = isTransient(err);
recordSlow(url, performance.now() - t0, err.message);
throw err;
}
recordSlow(url, performance.now() - t0);
const text = await resp.text();
let json = {};
try { json = text ? JSON.parse(text) : {}; } catch (e) {
const err = new Error("EtherCAT 服务响应非合法 JSON");
err.transient = true; /* 代理半截响应:下一拍重试即可,勿累计断线 */
throw err;
}
if (!resp.ok) {
const err = new Error(
(json.log && json.log.length) ? json.log.join(" / ") : ("HTTP " + resp.status));
err.status = resp.status;
err.transient = (resp.status === 500 || resp.status === 503 || resp.status === 429);
throw err;
}
return json;
}
async function api(path, body, timeoutMs, opts) {
const tag = String(path || "").replace(/^\/+/, "").split("?", 1)[0];
const preferHttp = !!(opts && opts.preferHttp) || HOT_HTTP_PATHS.has(tag);
const hasBridge = !!getDesktopEcatApi();
/* 热路径:HTTP 优先;失败再落桥(代理子进程挂了仍能用)。
* 低频路径:APP 壳里仍优先桥(send 等走 GUI 直连 native 更稳)。 */
if (preferHttp) {
try {
return await callHttp(tag, body, timeoutMs);
} catch (eHttp) {
if (!hasBridge) throw eHttp;
try {
return await callBridge(tag, body, timeoutMs);
} catch (eBridge) {
/* 两次都失败:优先抛 HTTP 错(与 isTransient 口径一致) */
throw eHttp;
}
}
}
if (hasBridge) {
const tBridge = performance.now();
try {
return await callBridge(tag, body, timeoutMs);
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
if (!err.transient && !/desktop bridge unavailable/i.test(err.message || "")) {
recordSlow("bridge:/" + tag, performance.now() - tBridge, err.message);
}
/* 落到 HTTP 兜底 */
}
}
return callHttp(tag, body, timeoutMs);
}
/**
* ECAT 发送统一入口。
* 桌面桥且 ecat_send_raw 可用:**只走 ESF1**,失败直接返回 ok:false,
* **禁止**回落 HTTP motor/send(2026-09-23 堵死慢发,防误走 JSON 路径)。
* 无桌面桥(浏览器标签):仍走 api("motor/send") HTTP。
* payload = {frames:[{ch,id,dlc,data}]}。
*/
let lastFastSendPath = "";
async function sendViaFastOrJson(payload) {
if (hasDesktopFastSend()) {
lastFastSendPath = "fast_tcp";
try {
const r = await desktopEcatSendRaw(payload.frames || [], 2000);
if (r && r.ok === true) {
if (r.path) lastFastSendPath = r.path;
return r;
}
const msg = (r && r.log && r.log.length)
? r.log.join(" / ")
: "二进制快发失败(已禁用 HTTP/JSON 回落)";
recordSlow("fast-send", 0, msg);
return { ok: false, queued: 0, backlog: 0, path: "fast_tcp", log: [msg] };
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
recordSlow("fast-send", 0, err.message);
return {
ok: false, queued: 0, backlog: 0, path: "fast_tcp",
log: [err.message || "二进制快发异常"],
};
}
}
lastFastSendPath = "json";
return api("motor/send", payload, 6000);
}
/** ok:false 是后端的常规业务错误信道(HTTP 仍是 200)。 */
function unwrap(json, what) {
if (!json || json.ok !== true) {
const msg = (json && json.log && json.log.length) ? json.log.join(" / ") : (what + "失败");
const err = new Error(msg);
/* 可自愈业务态:循环未起/未连接/忙/排队 —— ensureStarted/probe 会重开。
* 把这些算硬失败会在高压毛刺下 5 拍内把页面判成断线(实测踩过)。 */
err.transient = /未启动|未连接|周期异常|排队|busy|暂时|稍后|重试|超时|timed?\s*out|bridge|序列化/i.test(msg);
throw err;
}
return json;
}
/**
* 确保网卡已打开(从站已被发现)。
*
* 服务刚被自动拉起时是"没连接"状态(g_master=None),此时任何
* /api/motor/* 都会回 {ok:false, log:["未连接"]}。这里代替用户去点
* ecat.html 上的"连接"按钮 —— 否则"自动启动"只自动了一半,
* 用户还是得开另一个页面点两下才能用。
*
* 幂等:后端的 open 路由会先 close 掉旧 master 再重开,
* 所以只在确实没连接时才调,不要每拍都调(那会把总线反复重置)。
*/
async function ensureOpen() {
const st = await api("status", {}, 6000);
if (st && st.ok === true) return st; // 已连接
/* 未连接 → 打开网卡。substr 与 ecat.html 的默认值一致(I210);
* 换网卡的场合由 setAdapter() 覆盖,存 localStorage 供下次复用。 */
const r = await api("open", { substr: adapterSubstr }, 20000);
if (!r || r.ok !== true) {
const msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "打开网卡失败";
const err = new Error(msg);
/* 网线没插/板子没供电属"外部条件不满足",不是瞬时抖动:
* 标成 transient 会让页面一直转圈却不给红灯提示。
* 但"适配器没找到"往往是网卡名不同,值得把候选列表原样冒泡上去。 */
err.transient = false;
throw err;
}
return r;
}
/**
* 轻量健康:native/health 只读缓存(不抢 g_bus_lock、不 loop_stop)。
* bus_open + loop=1 即后台 PDO 循环在跑,从站看门狗正被喂着 —— 这时候
* 再打 motor/start 反而有害:它会先 loop_stop 再核实/重进 OP,热路径上
* 既慢又可能撞前端 AbortSignal 假超时,文档也写明「已连接只复用」。
*/
async function readHealth() {
const h = await api("native/health", {}, 2500);
if (!h || h.ok !== true) return null;
return {
busOpen: !!h.bus_open,
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。
* 判据是 ecat-bus.proveEcatLoop 的 'up'(三态里只有 up 算健康)。 */
async function isBusHealthy() {
try {
const h = await readHealth();
return proveEcatLoop(h && h.raw) === "up";
} catch (_) {
return false;
}
}
/** 从 motor/state 快照同步周期/自旋/钉核,供状态栏在跳过 start 后仍显示正确。 */
function syncFromSnapshot(snap) {
if (!snap) return;
if (snap.period_ms > 0) periodMs = snap.period_ms;
if (snap.slot_period_ms) slotPeriodMs = snap.slot_period_ms;
if (typeof snap.spin === "boolean") spinOn = snap.spin;
if (typeof snap.pcore_mask === "number") pcoreMask = snap.pcore_mask;
lastSnapshot = snap;
}
/** 确保已进 OP 且后台 PDO 循环在跑(1000ms 过程数据看门狗要求持续交换)。 */
async function ensureStarted(reqPeriodMs) {
/* 日常接入(无显式周期覆盖):循环还在跑就别动总线。
* motor/start 会先 loop_stop,停手 >1s 看门狗踢出 OP,重进还可能楔死邮箱。 */
if (!reqPeriodMs) {
if (await isBusHealthy()) {
started = true;
try {
const snap = unwrap(await api("motor/state", {}, 2500), "读电机快照");
syncFromSnapshot(snap);
return snap;
} catch (_) { /* 健康位说在跑但快照暂不可读:仍按已启动处理 */ }
return { ok: true, period_ms: periodMs, spin: spinOn };
}
}
const body = {};
/* PDO 周期不跟前端刷新率走:过程数据必须持续喂看门狗,
* 而界面刷新只是"读快照"。默认 4ms 由后端决定,这里只在显式给值时覆盖。
* 没显式给时带上 localStorage 里的上次取值(内核级 250us 档位能跨重启存活)。 */
if (reqPeriodMs) body.period_ms = reqPeriodMs;
else {
try {
if (!localStorage.getItem(LS_PERIOD_MIG)) {
if (parseFloat(localStorage.getItem(LS_PERIOD)) === 4) {
localStorage.removeItem(LS_PERIOD);
}
localStorage.setItem(LS_PERIOD_MIG, "1");
}
const v = parseFloat(localStorage.getItem(LS_PERIOD));
/* 用户显式设过才用;否则固定 0.25ms —— 1ms+DC 实测发送出口仍约 1s */
if (v > 0 && v !== 1) body.period_ms = v;
else body.period_ms = 0.25;
} catch (_) { body.period_ms = 0.25; }
}
/* EtherCAT 控制路径必须 DC SYNC0=250µs:
* free-run 或 DC=1ms 时,GD32 processOutput 消费输出 PDO 会拖到 ~1s;
* DC=250µs 时 motor/send → CAN 出口约 2ms。 */
{
let per = Number(body.period_ms) || 0.25;
if (per > 0.5) per = 0.25; /* 过慢周期直接钳回 250µs 档 */
body.period_ms = per;
body.dc_cycle_us = Math.round(per * 1000);
if (body.dc_cycle_us < 250) body.dc_cycle_us = 250;
body.dc_shift_us = 0;
}
/* 节拍源覆盖:只有用户显式设过才发,否则交给后端按周期自动决定
* (≤2ms 自旋)。存 "0" 可以强制回到省 CPU 的内核睡眠。 */
let spinPref = null;
try {
const v = localStorage.getItem(LS_SPIN);
if (v === "0" || v === "1") spinPref = (v === "1");
} catch (_) { /* 隐私模式下 localStorage 会抛,忽略即可 */ }
if (spinPref !== null) body.spin = spinPref;
/* motor/start 内部会在未进 OP 时自己跑 PREOP→SAFEOP→OP(还会自动重试一次),
* 最坏约 24s,所以超时给 30s —— 给小了会在正常进 OP 的过程中假失败。 */
const r = unwrap(await api("motor/start", body, 30000), "启动 PDO 循环");
periodMs = r.period_ms || periodMs;
if (typeof r.spin === "boolean") spinOn = r.spin;
started = true;
return r;
}
function xrxChangeKey(x) {
if (!x || !x.data) return "";
return x.id + "|" + x.dlc + "|" + x.data;
}
/** 从批量接收区/长帧快照来的帧:按 CAN ID 后 16 位分派到各累积器。
* fromXrx=true 表示段3快照复读,调用方须先做内容变更判断。 */
function dispatchFrame(id, dlc, dataHex, fromXrx) {
if (!dataHex) return;
const node = (id >> 16) & 0xF;
const sub = id & 0xFFFF;
if (sub === 0x0101) {
configs.set(node, dataHex); // CONFIG 目标值回报
} else if (sub === 0x00FF) {
const prev = versions.get(node);
/* drainRx / xrx 内容变化 = 新到达或新快照,写入 versions 供查询提取。
* 同内容 xrx 复读:只刷新 at,不 bump seq(避免 seq 虚高)。 */
if (fromXrx && prev && prev.version === dataHex && prev.seq != null) {
versions.set(node, {
node,
seq: prev.seq,
version: dataHex,
at: Date.now(),
});
return;
}
versions.set(node, {
node,
seq: (prev && prev.seq != null) ? prev.seq + 1 : 1,
version: dataHex,
at: Date.now(),
});
} else if (sub === 0x0400 || sub === 0x0401) {
nodeIdReports.set(node, dataHex);
}
}
/** 消费批量接收区(取走即移除,语义是消费不是查看)。 */
async function drainRx() {
const r = await api("motor/rx", {}, 3000);
if (!r || !r.frames) return 0;
let n = 0;
for (const f of r.frames) {
if (!f.valid) continue;
/* STATUS 已由状态窗口按槽聚合,重复解一遍纯属浪费 */
if ((f.id & 0xFFFF) === 0x0100) continue;
dispatchFrame(f.id, f.dlc, f.data, false);
n++;
}
return n;
}
/**
* 快照 → WiFi 的 /motor/state 契约。
* 关键点:online 不能直接用从站给的标志位,还要叠一层 seq 判活
* (阈值随实测回报周期自适应,见 refresh-rate.js 的 staleLimitMs)。
*
* ★ 快照槽位会「闪没」:dense 窗口 win_count 偶发 3→2(实测 wkc 仍 3/3),
* 电机整只从 motors[] 消失。若直接映射,UI 立刻「节点离线」+波形虚线。
* 这里对**刚见过**的槽做 last-known 保持:缺席但仍落在 staleLimit 窗口内
* → 沿用上一拍 payload 且 online=true;超过窗口才真离线。
*/
const lastSlot = new Map(); // "ch:node" -> {node,ch,dlc,seq,data}
const lastOnlineAt = new Map(); // "ch:node" -> 最近一次判定 online 的时刻
/** 从站 online=false 时的软保持:刚在线过又瞬时掉标志,不立刻翻离线 */
const ONLINE_FALSE_HOLD_MS = 500;
function snapshotToMotorState(snap) {
const now = Date.now();
const motors = [];
const present = new Set();
for (const m of (snap.motors || [])) {
const key = m.ch + ":" + m.node;
present.add(key);
const prev = seqSeen.get(key);
let rx = prev ? prev.rx : 0;
let at = prev ? prev.at : now;
let iv = prev ? prev.iv : 0;
if (!prev || prev.seq !== m.seq) {
if (prev) {
const d = now - prev.at;
iv = iv ? iv + (d - iv) * 0.3 : d;
}
rx = rx + 1;
at = now;
}
seqSeen.set(key, { seq: m.seq, at, rx, iv });
const fresh = (now - at) <= staleLimitMs(iv);
let online = !!m.online && fresh;
if (online) lastOnlineAt.set(key, now);
else if ((now - (lastOnlineAt.get(key) || 0)) < ONLINE_FALSE_HOLD_MS) {
online = true; /* 软保持:标志瞬时 false 不立刻打脸 UI */
}
lastSlot.set(key, {
node: m.node, ch: m.ch, dlc: m.dlc, seq: m.seq, data: m.data,
});
motors.push({
node: m.node,
ch: m.ch,
online,
dlc: m.dlc,
rx_count: rx,
seq: m.seq,
data: m.data,
config_data: configs.get(m.node) || "",
config_seq: 0,
config_rx_count: 0,
});
}
/* 本拍缺席的槽:仍在 stale 窗口内 → last-known 保持在线 */
for (const [key, slot] of lastSlot) {
if (present.has(key)) continue;
const prev = seqSeen.get(key);
if (!prev) continue;
if ((now - prev.at) > staleLimitMs(prev.iv)) continue;
motors.push({
node: slot.node,
ch: slot.ch,
online: true,
dlc: slot.dlc,
rx_count: prev.rx,
seq: prev.seq,
data: slot.data,
config_data: configs.get(slot.node) || "",
config_seq: 0,
config_rx_count: 0,
});
}
const head = snap.head || {};
return {
have_data: motors.length > 0,
active_count: head.active || 0,
age_ms: snap.ts ? Math.max(0, Math.round(now - snap.ts * 1000)) : null,
boot_active: !!head.boot_active,
motors,
};
}
return {
id: "ecat",
label: "EtherCAT",
/** EtherCAT 侧没有固件上传 + GD32 relay 链路,OTA 页签需禁用 */
supportsOta: false,
/** PDO 周期由后台循环独立维持(看门狗要求),不跟界面刷新率联动 */
supportsPeriodSync: false,
sendHint(ch) {
const fast = lastFastSendPath === "fast_tcp"
? "二进制快发"
: desktopBridgeLabel();
return fast + " · motor/send · CH" + ch +
(periodMs ? " · PDO " + fmtPeriod(periodMs) : "");
},
/**
* 连接探测 —— 一路自动到能用:
* 服务没起 → 代理侧按需拉起(esp32_server._ecat_autostart)
* 网卡没开 → ensureOpen() 代替用户点"连接"
* 循环已在跑 → 直接复用(健康位只读,**不**再打 motor/start)
* 没进 OP / 循环没跑 → ensureStarted() 走 motor/start
* 用户切到 EtherCAT 链路就能直接用,不必先开别的页面点两下。
*
* 只有 /api/status 通不算「从站在线」—— 循环没跑、从站没进 OP 时界面全是
* "--"。probe/healthCheck 只保证主站链路可用(刷写也要能进页面);
* 工具栏在线灯由 main.js 的 isTargetOnline 按 AL/op/STATUS 判定。
*
* **为什么健康时必须跳过 motor/start**:它在 _BUS_EXCLUSIVE 语义下会先
* loop_stop 再核实 OP/重进;外壳 Owner 可能已把总线跑起来,页面每次探测
* 都去停一次循环,既假超时(横幅「signal timed out」)又增加 SSC 楔死风险。
*/
async probe() {
const st = await ensureOpen();
await ensureStarted();
/* 把 native health 的 op 位带给 UI:在线灯要区分「主站栈在跑」和「从站在 OP」 */
try {
const h = await readHealth();
if (st && h && st.__op == null) st.__op = !!h.op;
} catch (_) { /* 健康位读不到时靠 status 的 AL 字段 */ }
return st;
},
/**
* 轻量健康复核(probe 超时后的「其实已经连上了」判定)。
* 只读 native/health + status + motor/state,绝不 motor/start、不动总线。
* 成功返回 status 数据(形状与 probe 一致);不健康则抛错。
* 注意:这里只判断「主站链路可用」,从站是否在线由 UI 的 isTargetOnline
* 按 AL/op/STATUS 再判 —— 刷写场景从站可以停在 PREOP/BOOT 仍需页面可用。
*/
async healthCheck() {
const h = await readHealth();
if (!h || !h.busOpen) throw new Error("EtherCAT 服务未打开网卡");
if (!h.loop) throw new Error("PDO 循环未运行");
/* DC 自愈只能走 ecat-bus.repairEcatDc:它"证明不了配错就不动总线"。
* 以前这里是内联的 dc !== 1 || dcs !== 250 —— 把"回包没有 dc 字段"当成
* "DC 配错",于是一篇错误回包/一份旧 exe 就打出一记 loop_stop 的
* motor/start,而这条路径恰好只在 probe() 超时(已有一记 loop_stop 在飞)后走。 */
try {
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);
} catch (_) { /* 快照暂不可读不影响健康判定 */ }
return st;
},
/** 健康检查:EtherCAT 没有 /system/status,用从站身份/AL 状态代替。 */
async fetchStatus() {
const st = unwrap(await api("status", {}, 6000), "读从站状态");
if (!started) await ensureStarted();
return st;
},
describeStatus(data) {
const s = (data && data.slave) || {};
const AL = { 0: "NONE", 1: "INIT", 2: "PREOP", 3: "BOOT", 4: "SAFEOP", 8: "OP" };
const parts = [];
parts.push("AL " + (AL[s.state] || ("0x" + Number(s.state || 0).toString(16))));
if (s.al_status) parts.push("code 0x" + Number(s.al_status).toString(16).toUpperCase().padStart(4, "0"));
if (s.name) parts.push(s.name);
/* PDO 周期 = 总线速度;单槽周期 = 某个电机的状态多久更新一次。
* 两者不等时(电机分散在多个窗口)必须都显示,否则用户会拿 PDO 周期
* 当成数据新鲜度 —— 那正是"看着 4ms 实际 16ms"的谎报。 */
if (periodMs) {
parts.push(slotPeriodMs && slotPeriodMs !== periodMs
? "PDO " + fmtPeriod(periodMs) + " \u00b7 单槽 " + fmtPeriod(slotPeriodMs)
: "PDO " + fmtPeriod(periodMs));
}
/* 自旋会占满一个核,属于用户能感知的代价,必须在状态栏可见而不是只写在文档里。
* 钉到 P 核时把核号一起写出来,用户才知道占的是性能核。 */
if (periodMs && spinOn) {
const rng = pcoreRanges(pcoreList(pcoreMask));
parts.push(rng ? `⚡ 自旋节拍 · 钉在 P 核 ${rng}` : "⚡ 自旋节拍(占 1 核)");
}
return parts;
},
/** EtherCAT 不经 IP 通信(走网卡二层),不回报设备 IP。 */
reportedIp() { return null; },
async fetchMotorState() {
const snap = unwrap(await api("motor/state", {}, 4000), "读电机快照");
lastSnapshot = snap;
/* 单槽刷新周期随在线电机分布变化(窗口自适应),每拍跟着更新 */
if (snap.slot_period_ms) slotPeriodMs = snap.slot_period_ms;
/* PDO 周期也可能被别的入口热改过(rate_probe 脚本、另一个标签页),
* 每拍从快照同步,不然状态栏会一直显示本页面记忆里的旧周期。 */
if (snap.period_ms > 0) periodMs = snap.period_ms;
if (typeof snap.spin === "boolean") spinOn = snap.spin;
if (typeof snap.pcore_mask === "number") pcoreMask = snap.pcore_mask;
/* 消费批量接收区。
* queued = 队列深度(可取帧数);count/pending 是本拍 PDO 新增/溢出。
* 三者任一有值才 drain。旧后端无 queued 时,查询等待期靠 drainPendingRx 兜底。 */
const rxb = snap.rx_batch;
const needDrain = !!(rxb && (
rxb.count > 0 ||
rxb.pending > 0 ||
(typeof rxb.queued === "number" && rxb.queued > 0)
));
/* 节流 drain:6ms 档下每拍 drain 会把 HTTP 请求量翻倍。
* queued 很深(积压)时强制立刻取,避免版本/CONFIG 应答延迟。 */
const nowDrain = Date.now();
const backlog = (rxb && (rxb.queued || rxb.pending || rxb.count)) || 0;
if (needDrain && (backlog >= 32 || (nowDrain - lastDrainAt) >= DRAIN_MIN_MS)) {
lastDrainAt = nowDrain;
try { await drainRx(); } catch (e) { /* 瞬时失败下拍再取 */ }
}
/* 段3 xrx:通用长帧快照(CONFIG/版本/节点ID)。
* 内容变化时分派进 configs/versions;查询路径另用 peekVersionXrx
* 直接读 snap,不依赖这里的 lastXrxKey 去重。 */
const xk = xrxChangeKey(snap.xrx);
if (xk) {
if (xk !== lastXrxKey) {
lastXrxKey = xk;
dispatchFrame(snap.xrx.id, snap.xrx.dlc, snap.xrx.data, true);
}
} else {
lastXrxKey = "";
}
return snapshotToMotorState(snap);
},
/**
* CAN 反馈(状态栏)。直接复用上一次快照里的 bridge,不再打一次后端:
* 那本来就是同一份过程数据,多打一次只会拿到同样的值。
*
* 逐字段说明为什么很多是 null:过程数据的 bridge 段是**桥全局**计数,
* 没有按通道拆分,也完全没有 TEC/REC。按监控 UI 的约定,
* "通信正常但无此数据" 必须显示 "--",不能编 0 冒充正常。
*/
async fetchFeedback() {
const snap = lastSnapshot;
const b = (snap && snap.bridge) || null;
const linkOk = !!(snap && b && b.valid && snap.wkc === snap.expected_wkc);
const channels = [];
for (let ch = 0; ch < 3; ch++) {
channels.push({
ch,
enabled: true, // 过程数据不回报通道启用状态,不能据此禁止发送
valid: linkOk,
rx: linkOk ? b.rx_count : null,
tx: linkOk ? b.tx_count : null,
err: linkOk ? b.drop : null,
tec: null, // 过程数据里没有
rec: null, // 过程数据里没有
bus_state: linkOk ? (b.tx_result === 0xE2 ? 8 : 0) : null,
});
}
return { channels };
},
/**
* 发一批帧:一次提交的多帧被后端塞进同一个 PDO 周期的批量发送区,
* 在**同一个 seq 沿**由从站一次性发出 —— 这是多轴同步下发的前提,
* 逐帧调用会退化成一帧一个周期。
* 返回值补成 WiFi 的 results[] 形状(后端只回 queued/backlog)。
*
* PDO 快发:桌面桥有 ecat_send_raw 时**只走** ESF1 二进制直通,
* 失败 ok:false、不回落 JSON(堵死慢发)。无桥(浏览器)才走 motor/send。
*/
async sendFrames(frames, opts) {
if (!started) await ensureStarted();
const payload = {
frames: frames.map((f) => ({
ch: opts.ch, id: f.id, dlc: f.dlc, data: f.data,
})),
};
const r = await sendViaFastOrJson(payload);
if (!r || r.ok !== true) {
const msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "发送失败";
return frames.map(() => ({ status: "error", code: -2, gd32_err: 0, desc: msg }));
}
/* 后端不做逐帧回执(帧是同一个 seq 沿一次性发出的),
* 统一回 ok;真正的总线级错误在下一拍 bridge.tx_result 里体现。 */
return frames.map(() => ({ status: "ok" }));
},
/** 突发发送:EtherCAT 的批量区天然就是"同一 seq 沿",直接复用 sendFrames。 */
async sendBurst(frames, opts) {
const built = frames.map((f) => ({
id: ((f.node & 0xF) << 16) | (opts.suffix & 0xFFFF),
dlc: opts.dlc,
data: f.data,
ch: f.ch,
}));
if (!started) await ensureStarted();
const r = await sendViaFastOrJson({ frames: built });
if (!r || r.ok !== true) {
const msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "发送失败";
return built.map(() => ({ status: "error", code: -2, gd32_err: 0, desc: msg }));
}
return built.map(() => ({ status: "ok" }));
},
/** 版本:由批量接收区累积(见模块头注释第 2 点)。 */
async fetchVersionNode(node) {
return versions.get(node) || null;
},
/** 版本查询等待期强制消费 motor/rx(不依赖 rx_batch.count)。 */
drainPendingRx() {
return drainRx();
},
/**
* 直接看 PDO 段3 xrx 是否正带着「本节点」的 0x00FF。
* xrx 是共享快照、不会被 drain 消费;seq 不增长时也能命中。
* 用于破解「xrx 上是别的节点版本 / 同内容不 bump seq」导致的阵发全败。
*/
async peekVersionXrx(node) {
try {
const st = await api("motor/state", {}, 2500);
lastSnapshot = st;
if (st && st.period_ms > 0) periodMs = st.period_ms;
const x = st && st.xrx;
if (!x || !x.data) return null;
const id = (x.id >>> 0);
if (((id >> 16) & 0xF) !== node || (id & 0xFFFF) !== 0x00FF) return null;
const prev = versions.get(node);
if (!prev || prev.version !== x.data) {
dispatchFrame(id, x.dlc, x.data, true);
}
const cur = versions.get(node);
return {
node,
seq: cur && cur.seq != null ? cur.seq : (prev && prev.seq != null ? prev.seq : 1),
version: x.data,
at: Date.now(),
src: "xrx",
};
} catch (_) {
return null;
}
},
/** 节点 ID 应答:异步到达,取走即清(供 main.js 在发完 GET 后短轮询)。 */
takeNodeIdReport(node) {
const hex = nodeIdReports.get(node);
if (hex) nodeIdReports.delete(node);
return hex || null;
},
/** PDO 周期由后台循环维持,与界面刷新率无关,故不实现。 */
async setPeriod() { /* no-op:见 supportsPeriodSync */ },
/**
* 切走本传输:**故意不停后台 PDO 循环**。
* 从站 SM2 有 1000ms 过程数据看门狗,停超 1 秒会 OP→SAFEOP 掉线报 AL 0x001B;
* 用户只是切到 WiFi 看看,不该把 EtherCAT 链路打断。
* 真正该停的时机是"整个 app 关掉"—— 由服务端的 app 租约负责:
* 最后一个 app 窗口断开后 esp32_server 会调 /api/op/stop 停循环并退回 INIT,
* 这里(页面内)什么都不用做。
*/
async teardown() { started = false; lastSnapshot = null; },
/** 当前网卡匹配子串(工具栏输入框回填用)。 */
getAdapter() { return adapterSubstr; },
/**
* 换网卡:立即重开并重进 OP。
* 换网卡意味着当前 master 指向错的网口,留着旧连接毫无意义,
* 所以这里主动 close 让下一次 ensureOpen 走全新的 open。
*/
async setAdapter(substr) {
const v = String(substr || "").trim();
if (!v || v === adapterSubstr) return false;
adapterSubstr = v;
try { deviceLink.setEcatAdapter(v); } catch (_) {}
started = false;
lastSnapshot = null;
try { await api("close", {}, 8000); } catch (_) { /* 没连接时无所谓 */ }
return true;
},
/**
* 设节拍源:true=自旋(周期跑得准,占满一个核),false=内核睡眠(省 CPU,
* 周期超调约 0.5ms),null=交回后端按周期自动决定。
*
* 必须走 /api/motor/spin 而不是 /api/motor/start:后者在
* _BUS_EXCLUSIVE_PATHS 里,dispatcher 会先停掉 PDO 循环再执行,而停手超过
* 从站那 1000ms 过程数据看门狗就会把它踢出 OP,重进时还可能撞上 SSC 邮箱
* 楔死(实测把一块好板子弄死过两次)。自旋只是循环里读的一个标志,热改即可。
*/
async setSpin(v) {
try {
if (v === null || v === undefined) localStorage.removeItem(LS_SPIN);
else localStorage.setItem(LS_SPIN, v ? "1" : "0");
} catch (_) {}
const body = (v === null || v === undefined) ? {} : { spin: !!v };
const r = unwrap(await api("motor/spin", body, 8000), "切换节拍源");
if (typeof r.spin === "boolean") spinOn = r.spin;
if (r.period_ms) periodMs = r.period_ms;
return spinOn;
},
/**
* 热换 PDO 周期(毫秒)。硬顶 0.5ms:≥1ms 时 GD32 输出 PDO 消费会拖到秒级。
* 后端 motor/period 会同步把 DC SYNC0 对齐到同一 µs 值。
*/
async setPdoPeriod(ms) {
let v = Number(ms);
if (!(v > 0)) throw new Error("周期必须是 >0 的毫秒数,收到: " + ms);
if (v > 0.25) v = 0.25; /* 钉死 250µs,杜绝 0.5/1ms */
if (v < 0.0625) v = 0.25;
const r = unwrap(await api("motor/period", { period_ms: v }, 8000), "热改 PDO 周期");
if (typeof r.period_ms === "number") periodMs = r.period_ms;
else periodMs = v;
if (typeof r.spin === "boolean") spinOn = r.spin;
try { localStorage.setItem(LS_PERIOD, String(periodMs)); } catch (_) {}
return periodMs;
},
/** 诊断用(控制台 motorDbg.transport)。 */
debug() {
return {
periodMs, slotPeriodMs, spinOn, pcoreMask, pcores: pcoreList(pcoreMask),
started, adapter: adapterSubstr,
configs: configs.size, versions: versions.size,
slots: seqSeen.size, snapshot: lastSnapshot,
slotsPerPdo: MOT_SLOTS_PER_PDO,
slotCount: MOTOR_SLOT_COUNT,
scanCycles: MOTOR_SCAN_CYCLES,
winBases: (lastSnapshot && lastSnapshot.win_bases) || null,
};
},
};
}