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:
+338
-54
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user