feat(web): 桌面原生桥 + 配置落盘 + 无设备静默态;排除调试脚本

变更点:
1. esp32_server.py
   - /api/motor/ui-state、/api/lab/ui-state:配置原子落盘 data/*.json(WebView2 清存储仍在)
   - _GuiApi.ecat_api/bridge_info:桌面壳经 GUI 进程裸 socket 直连 ecat_native,不经浏览器 HTTP
2. js/common/desktop-bridge.js(新)
   - 探测 pywebview.api(含 iframe top/parent),transport-ecat 优先走原生桥
3. connection.js + responsive.css + components.css
   - setQuietMode:离线且无传输时 html[data-dcu-calm=1] 掐常驻动画
   - 进度条斜纹仅 .live 挂载,空闲不再按刷新率空转重绘
4. device-link.js
   - 先查 native/health,已健康跳过 motor/start,避免 loop_stop 重进 OP 假超时
   - 周期强制 0.25ms + DC SYNC0 对齐;轮询自愈被配错的 DC/周期
5. motor/main.js + transport-ecat.js
   - 配置双写 localStorage + 磁盘 ui-state;pagehide/sendBeacon 刷盘;导入导出
   - 探测代际 probeGen;ECAT 失败阈值 12;跟踪 bootActive/slaveAl/slaveOp
6. lab/spi_test.html、ota/boot_ecat.js、can_monitor 等跟随桥/静默态
7. .gitignore
   - 排除 scripts/_* 与 anal/collect/measure/trace 等调试脚本、lab 产物、data/ 运行态
This commit is contained in:
cbb
2026-09-20 22:58:00 +08:00
parent e91d53e0fc
commit 383535bd1d
23 changed files with 2060 additions and 283 deletions
+338 -54
View File
@@ -1,9 +1,12 @@
/**
* transport-ecat.js — 电机控制台的 EtherCAT 传输实现
*
* 后端是 tools/ecat 的 ecat_native.exe(C/SOEM 守护进程,另一个 git 仓库),经本地页面服务器
* 的 /ecat/api/* 反向代理同源访问 —— 那个服务只绑 127.0.0.1 且没有 CORS,
* 浏览器直连跨端口会卡在预检上,详见 esp32_server.py 的 _ecat_proxy 注释。
* 后端是 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(已有实例只复用)
@@ -11,6 +14,9 @@
* 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 面板 / 波形 / 命令序列全部原样复用。
@@ -36,6 +42,9 @@
* 「ECAT 槽判活阈值」一节,纯函数以便脱离 DOM 自测。 */
import { staleLimitMs } from "./refresh-rate.js";
import { deviceLink } from "../common/device-link.js";
import {
desktopEcatPost, desktopBridgeLabel, getDesktopEcatApi,
} from "../common/desktop-bridge.js?v=20260923g";
/** 单个 PDO 周期回传的状态槽数。
* 固件 1.6.21 起段 2 密排整轮:一拍带齐总线电机(最多 9),空槽不占位。
@@ -101,8 +110,11 @@ export function createEcatTransport(ctx) {
/* ── 传输内部累积状态 ──
* 这些都是"WiFi 侧由固件/GD32 代为缓存、EtherCAT 侧必须前端自己攒"的东西。 */
const configs = new Map(); // node -> 24B CONFIG hex(0x000N0101)
const versions = new Map(); // node -> {node, seq, version} 供 fetchVersionNode
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)。
@@ -132,10 +144,35 @@ export function createEcatTransport(ctx) {
let started = false; // 后台循环是否已启动
let lastSnapshot = null; // 最近一次快照,供 fetchFeedback 复用(不重复打后端)
/** POST 到本地代理。失败抛 Error,附 transient,与 WiFi 传输的错误契约一致。 */
async function api(path, body, timeoutMs) {
const url = "/ecat/api/" + path.replace(/^\/+/, "");
/**
* 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, {
@@ -155,28 +192,64 @@ export function createEcatTransport(ctx) {
const text = await resp.text();
let json = {};
try { json = text ? JSON.parse(text) : {}; } catch (e) {
throw new Error("EtherCAT 服务响应非合法 JSON");
const err = new Error("EtherCAT 服务响应非合法 JSON");
err.transient = true; /* 代理半截响应:下一拍重试即可,勿累计断线 */
throw err;
}
if (!resp.ok) {
/* 代理连不上后端会给 503 + 同构信封;后端的 404 体里带
* "服务改过但没重启"的排查提示,两者都在 log[] 里,原样冒泡给用户。 */
const err = new Error(
(json.log && json.log.length) ? json.log.join(" / ") : ("HTTP " + resp.status));
err.status = resp.status;
err.transient = (resp.status === 503);
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);
}
/** 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);
/* "后台 PDO 循环未启动" 是可自愈的:下一拍 ensureStarted 会重开,
* 归为瞬时错误,避免刚切过来的一瞬间就把页面判成断线。 */
err.transient = /未启动|周期异常|排队|busy/i.test(msg);
/* 可自愈业务态:循环未起/未连接/忙/排队 —— ensureStarted/probe 会重开。
* 把这些算硬失败会在高压毛刺下 5 拍内把页面判成断线(实测踩过)。 */
err.transient = /未启动|未连接|周期异常|排队|busy|暂时|稍后|重试|超时|timed?\s*out|bridge|序列化/i.test(msg);
throw err;
}
return json;
@@ -211,8 +284,58 @@ export function createEcatTransport(ctx) {
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,
};
}
/** 总线已开 + PDO 循环在跑 → 视为链路就绪,跳过 motor/start。 */
async function isBusHealthy() {
try {
const h = await readHealth();
return !!(h && h.busOpen && h.loop);
} 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 由后端决定,这里只在显式给值时覆盖。
@@ -227,8 +350,21 @@ export function createEcatTransport(ctx) {
localStorage.setItem(LS_PERIOD_MIG, "1");
}
const v = parseFloat(localStorage.getItem(LS_PERIOD));
if (v > 0) body.period_ms = v;
} catch (_) { /* 隐私模式忽略 */ }
/* 用户显式设过才用;否则固定 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 的内核睡眠。 */
@@ -247,8 +383,14 @@ export function createEcatTransport(ctx) {
return r;
}
/** 从批量接收区/长帧快照来的帧:按 CAN ID 后 16 位分派到各累积器。 */
function dispatchFrame(id, dlc, dataHex) {
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;
@@ -256,10 +398,22 @@ export function createEcatTransport(ctx) {
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 : 0) + 1, // 自建 seq:让上层"seq 增长=新应答"逻辑通用
seq: (prev && prev.seq != null) ? prev.seq + 1 : 1,
version: dataHex,
at: Date.now(),
});
} else if (sub === 0x0400 || sub === 0x0401) {
nodeIdReports.set(node, dataHex);
@@ -269,64 +423,101 @@ export function createEcatTransport(ctx) {
/** 消费批量接收区(取走即移除,语义是消费不是查看)。 */
async function drainRx() {
const r = await api("motor/rx", {}, 3000);
if (!r || !r.frames) return;
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);
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) {
/* 实测变化间隔 EWMA(α=0.3):判活阈值跟着电机回报率走。电机改回报
* 档后 ~3 次新间隔收敛;突发降速无法预知,收敛前最多闪一次离线。 */
const d = now - prev.at;
iv = iv ? iv + (d - iv) * 0.3 : d;
}
rx = rx + 1; // 本地累计"看到过多少次更新",补 WiFi 的 rx_count 语义
rx = rx + 1;
at = now;
}
seqSeen.set(key, { seq: m.seq, at, rx, iv });
/* 从站说在线 **且** seq 还在动才算在线:快照永不删除历史槽。
* 阈值随实测回报周期放大(慢速电机不误判),下限 2s 兜底快周期。 */
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,
/* 从站说在线 **且** seq 还在动才算在线:快照永不删除历史槽 */
online: !!m.online && fresh,
online,
dlc: m.dlc,
rx_count: rx,
seq: m.seq,
data: m.data,
/* CONFIG 从批量接收区累积而来(WiFi 侧是每电机自带的) */
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,
/* 快照 ts 是服务端 time.time()(秒);与本地时钟比会受偏差影响,
* 只用它算个量级即可,UI 也只把 age 当"有没有数据"的粗指标。 */
age_ms: snap.ts ? Math.max(0, Math.round(now - snap.ts * 1000)) : null,
boot_active: !!head.boot_active,
motors,
@@ -342,24 +533,66 @@ export function createEcatTransport(ctx) {
supportsPeriodSync: false,
sendHint(ch) {
return "POST /api/motor/send \u00b7 CH" + ch +
(periodMs ? " \u00b7 PDO " + fmtPeriod(periodMs) : "");
return desktopBridgeLabel() + " · motor/send · CH" + ch +
(periodMs ? " · PDO " + fmtPeriod(periodMs) : "");
},
/**
* 连接探测 —— 一路自动到能用:
* 服务没起 → 代理侧按需拉起(esp32_server._ecat_autostart)
* 网卡没开 → ensureOpen() 代替用户点"连接"
* 没进 OP → ensureStarted() 里 motor/start 自己走 PREOP→SAFEOP→OP
* 循环没跑 → ensureStarted() 起后台 PDO 循环(喂 1000ms 看门狗)
* 四步都自动,用户切到 EtherCAT 链路就能直接用,不必先开别的页面点两下。
* 循环已在跑 → 直接复用(健康位只读,**不**再打 motor/start)
* 没进 OP / 循环没跑 → ensureStarted() 走 motor/start
* 用户切到 EtherCAT 链路就能直接用,不必先开别的页面点两下。
*
* 只有 /api/status 通不算连上 —— 循环没跑就读不到任何电机状态,
* 那样 LED 会绿着但界面全是 "--",比直接报未连接更难排查。
* 只有 /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/周期被配错(free-run 或 ≥1ms)时发送出口会到秒级 —— 拉回 250µs */
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);
}
} 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;
try {
const snap = await api("motor/state", {}, 2500);
if (snap && snap.ok === true) syncFromSnapshot(snap);
} catch (_) { /* 快照暂不可读不影响健康判定 */ }
return st;
},
@@ -407,14 +640,34 @@ export function createEcatTransport(ctx) {
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;
if (rxb && (rxb.count > 0 || rxb.pending > 0)) {
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) { /* 瞬时失败下拍再取 */ }
}
/* 长帧快照(CONFIG/版本这类 DLC24 帧在 8B 短快照里会被截断)也要分派 */
if (snap.xrx && snap.xrx.data) {
dispatchFrame(snap.xrx.id, snap.xrx.dlc, snap.xrx.data);
/* 段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);
},
@@ -493,6 +746,42 @@ export function createEcatTransport(ctx) {
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);
@@ -554,22 +843,17 @@ export function createEcatTransport(ctx) {
},
/**
* 热换 PDO 周期(毫秒,允许亚毫秒:0.25 = 4kHz)。真机总线一送一回约
* 165µs,比这更快会被循环自然自限,报多少都不会弄坏东西。
*
* 走 /api/motor/period 而不是 /api/motor/start,理由与 setSpin 相同:
* motor/start 在总线互斥表里会先停循环,停手 >1s 从站掉 OP、重进可能楔死;
* 周期只是循环每拍重读的变量,热改全程喂狗不断流,随时可调可反复调。
* 亚毫秒周期自动就是自旋节拍(内核睡眠粒度 ~0.5ms 跑不出 250µs)。
*
* 取值存 localStorage:后端重启后 ensureStarted 会原样带回。
* 恢复默认:删掉 dcu_ecat_period_ms,跟随后端默认(固件修好前别低于 1ms)。
* 热换 PDO 周期(毫秒)。硬顶 0.5ms:≥1ms 时 GD32 输出 PDO 消费会拖到秒级。
* 后端 motor/period 会同步把 DC SYNC0 对齐到同一 µs 值。
*/
async setPdoPeriod(ms) {
const v = Number(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;