feat(motor): 支持 WiFi 与 EtherCAT 双链路传输
- 抽象电机传输层,WiFi 与 EtherCAT 实现分离,解码、状态面板、波形和命令序列共用 - 新增 EtherCAT 后端反向代理与自动拉起,解决跨端口 CORS 与预检失败问题 - 刷新速度按链路独立存储,支持 EtherCAT 低延迟读取与 WiFi 稳定轮询 - 更新电机页链路选择、不支持页签置灰样式、README 说明及相关测试脚本
This commit is contained in:
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* transport-ecat.js — 电机控制台的 EtherCAT 传输实现
|
||||
*
|
||||
* 后端是 tools/ecat/src/ecat_server.py(另一个 git 仓库),经本地页面服务器
|
||||
* 的 /ecat/api/* 反向代理同源访问 —— 那个服务只绑 127.0.0.1 且没有 CORS,
|
||||
* 浏览器直连跨端口会卡在预检上,详见 esp32_server.py 的 _ecat_proxy 注释。
|
||||
*
|
||||
* **全自动接入**:切到本链路不需要任何手工前置步骤。四级按需自愈:
|
||||
* 1. 服务没起 → 代理侧 _ecat_autostart() 用 tools/ecat\venv 拉起(已有实例只复用)
|
||||
* 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 重来一遍,所以中途拔网线再插也会自己恢复。
|
||||
*
|
||||
* 本模块的**唯一职责**是把 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";
|
||||
|
||||
/** 单个 PDO 周期回传的状态槽数(与 ecat_server.MOT_SLOTS_PER_PDO 一致) */
|
||||
const MOT_SLOTS_PER_PDO = 7;
|
||||
/** 状态槽总数 = 9 节点 × 3 通道(ecat_server.MOTOR_SLOT_COUNT) */
|
||||
const MOTOR_SLOT_COUNT = 27;
|
||||
/** 扫完全表所需周期数(ecat_server.MOTOR_SCAN_CYCLES)。
|
||||
* 注意:这**只**是开机首圈全表扫描的窗口数,不是稳态下单槽的刷新分频 ——
|
||||
* ecat_server 会把窗口收缩到只盖有主的槽(窗口自适应),稳态下在线电机
|
||||
* 每个 PDO 周期都刷新。要显示"数据多新"请用快照里的 slot_period_ms。 */
|
||||
const MOTOR_SCAN_CYCLES = 4;
|
||||
|
||||
/**
|
||||
* 槽 seq 停止增长多久算离线(ms)。
|
||||
* 为什么需要它:ecat_server 的快照把见过的槽**永久累积**,电机拔了也不会消失,
|
||||
* 只能靠 seq 是否还在涨来判活。阈值要大于"扫完全表 + 一点抖动",否则
|
||||
* 慢周期下会把正常电机误判离线:period 10ms × 4 周期 = 40ms,取 2s 有充足余量。
|
||||
*/
|
||||
const STALE_OFFLINE_MS = 2000;
|
||||
|
||||
/** 位掩码 → 逻辑处理器号列表。JS 移位只有 32 位,所以用除法逐位取。 */
|
||||
function ecoreList(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 的核,本机 E 核就在 16..27,但大核数机器会越过 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);
|
||||
}
|
||||
|
||||
/** 连续核号压成区间:[16..27] → "16-27"。状态栏只有一行,
|
||||
* 12 个核号全列出来会把从站名挤掉,而"16-27"信息量一样。 */
|
||||
function ecoreRanges(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(",");
|
||||
}
|
||||
|
||||
/** @description 创建 EtherCAT 传输实例。 */
|
||||
export function createEcatTransport(ctx) {
|
||||
const { recordSlow, isTransient } = ctx;
|
||||
|
||||
/* 网卡匹配子串。与 ecat.html 的输入框默认值一致(I226);
|
||||
* 存 localStorage 以便换网卡后不用每次重设。 */
|
||||
const LS_ADAPTER = "dcu_ecat_adapter";
|
||||
let adapterSubstr = "I226";
|
||||
try {
|
||||
const saved = localStorage.getItem(LS_ADAPTER);
|
||||
if (saved && saved.trim()) adapterSubstr = saved.trim();
|
||||
} catch (_) {}
|
||||
|
||||
/* ── 传输内部累积状态 ──
|
||||
* 这些都是"WiFi 侧由固件/GD32 代为缓存、EtherCAT 侧必须前端自己攒"的东西。 */
|
||||
const configs = new Map(); // node -> 24B CONFIG hex(0x000N0101)
|
||||
const versions = new Map(); // node -> {node, seq, version} 供 fetchVersionNode
|
||||
const nodeIdReports = new Map();// node -> 应答 hex(0x000N0400/0401)
|
||||
const seqSeen = new Map(); // "ch:node" -> {seq, at, rx} 判活 + 计更新次数
|
||||
let periodMs = 0; // 后台 PDO 循环周期(由 /api/motor/start 回报)
|
||||
/* 单个电机状态的真实刷新周期(ms,来自快照的 slot_period_ms)。
|
||||
* 与 periodMs 的区别是要点:PDO 周期是"总线跑多快",而某个电机的状态要等
|
||||
* 窗口轮到它才更新。ecat_server 的窗口自适应会把窗口收缩到只盖有主的槽,
|
||||
* 稳态下两者相等;但开机首圈、或多台电机分散落在多个窗口时,
|
||||
* slotPeriodMs 会是 periodMs 的整数倍。状态栏显示这个才不谎报"数据多新"。 */
|
||||
let slotPeriodMs = 0;
|
||||
/* 节拍源是否为自旋(来自 motor/start 回报与快照)。
|
||||
* 必须显示出来:自旋能把 PDO 周期跑准(实测抖动 0.000ms),代价是**吃满一个
|
||||
* CPU 核**。用户把刷新调到 1ms 档时默认就会自旋,不写清楚的话 CPU 占用
|
||||
* 看起来像凭空冒出来的。可用 dcu_ecat_spin 显式覆盖("0"=强制内核睡眠)。 */
|
||||
let spinOn = false;
|
||||
/* 自旋线程被钉到的逻辑处理器位掩码(快照 ecore_mask)。0 = 没钉(对称 CPU
|
||||
* 或探测失败)。混合架构下钉到 E 核才有意义,所以要把落在哪些核显示出来。 */
|
||||
let ecoreMask = 0;
|
||||
const LS_SPIN = "dcu_ecat_spin";
|
||||
let started = false; // 后台循环是否已启动
|
||||
let lastSnapshot = null; // 最近一次快照,供 fetchFeedback 复用(不重复打后端)
|
||||
|
||||
/** POST 到本地代理。失败抛 Error,附 transient,与 WiFi 传输的错误契约一致。 */
|
||||
async function api(path, body, timeoutMs) {
|
||||
const url = "/ecat/api/" + path.replace(/^\/+/, "");
|
||||
const t0 = performance.now();
|
||||
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) {
|
||||
throw new Error("EtherCAT 服务响应非合法 JSON");
|
||||
}
|
||||
if (!resp.ok) {
|
||||
/* 代理连不上后端会给 503 + 同构信封;ecat_server 的 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);
|
||||
throw err;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/** ok:false 是 ecat_server 的常规业务错误信道(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);
|
||||
throw err;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保网卡已打开(从站已被发现)。
|
||||
*
|
||||
* 服务刚被自动拉起时是"没连接"状态(g_master=None),此时任何
|
||||
* /api/motor/* 都会回 {ok:false, log:["未连接"]}。这里代替用户去点
|
||||
* ecat.html 上的"连接"按钮 —— 否则"自动启动"只自动了一半,
|
||||
* 用户还是得开另一个页面点两下才能用。
|
||||
*
|
||||
* 幂等:ecat_server 的 api_open 会先 close 掉旧 master 再重开,
|
||||
* 所以只在确实没连接时才调,不要每拍都调(那会把总线反复重置)。
|
||||
*/
|
||||
async function ensureOpen() {
|
||||
const st = await api("status", {}, 6000);
|
||||
if (st && st.ok === true) return st; // 已连接
|
||||
/* 未连接 → 打开网卡。substr 与 ecat.html 的默认值一致(I226);
|
||||
* 换网卡的场合由 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;
|
||||
}
|
||||
|
||||
/** 确保已进 OP 且后台 PDO 循环在跑(1000ms 过程数据看门狗要求持续交换)。 */
|
||||
async function ensureStarted(reqPeriodMs) {
|
||||
const body = {};
|
||||
/* PDO 周期不跟前端刷新率走:过程数据必须持续喂看门狗,
|
||||
* 而界面刷新只是"读快照"。默认 4ms 由后端决定,这里只在显式给值时覆盖。 */
|
||||
if (reqPeriodMs) body.period_ms = reqPeriodMs;
|
||||
/* 节拍源覆盖:只有用户显式设过才发,否则交给后端按周期自动决定
|
||||
* (≤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;
|
||||
}
|
||||
|
||||
/** 从批量接收区/长帧快照来的帧:按 CAN ID 后 16 位分派到各累积器。 */
|
||||
function dispatchFrame(id, dlc, dataHex) {
|
||||
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);
|
||||
versions.set(node, {
|
||||
node,
|
||||
seq: (prev ? prev.seq : 0) + 1, // 自建 seq:让上层"seq 增长=新应答"逻辑通用
|
||||
version: dataHex,
|
||||
});
|
||||
} 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;
|
||||
for (const f of r.frames) {
|
||||
if (!f.valid) continue;
|
||||
/* STATUS 已由状态窗口按槽聚合,重复解一遍纯属浪费 */
|
||||
if ((f.id & 0xFFFF) === 0x0100) continue;
|
||||
dispatchFrame(f.id, f.dlc, f.data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 快照 → WiFi 的 /motor/state 契约。
|
||||
* 关键点:online 不能直接用从站给的标志位,还要叠一层 seq 判活(见 STALE_OFFLINE_MS)。
|
||||
*/
|
||||
function snapshotToMotorState(snap) {
|
||||
const now = Date.now();
|
||||
const motors = [];
|
||||
for (const m of (snap.motors || [])) {
|
||||
const key = m.ch + ":" + m.node;
|
||||
const prev = seqSeen.get(key);
|
||||
let rx = prev ? prev.rx : 0;
|
||||
let at = prev ? prev.at : now;
|
||||
if (!prev || prev.seq !== m.seq) {
|
||||
rx = rx + 1; // 本地累计"看到过多少次更新",补 WiFi 的 rx_count 语义
|
||||
at = now;
|
||||
}
|
||||
seqSeen.set(key, { seq: m.seq, at, rx });
|
||||
const fresh = (now - at) <= STALE_OFFLINE_MS;
|
||||
motors.push({
|
||||
node: m.node,
|
||||
ch: m.ch,
|
||||
/* 从站说在线 **且** seq 还在动才算在线:快照永不删除历史槽 */
|
||||
online: !!m.online && fresh,
|
||||
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,
|
||||
});
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: "ecat",
|
||||
label: "EtherCAT",
|
||||
/** EtherCAT 侧没有固件上传 + GD32 relay 链路,OTA 页签需禁用 */
|
||||
supportsOta: false,
|
||||
/** PDO 周期由后台循环独立维持(看门狗要求),不跟界面刷新率联动 */
|
||||
supportsPeriodSync: false,
|
||||
|
||||
sendHint(ch) {
|
||||
return "POST /api/motor/send \u00b7 CH" + ch +
|
||||
(periodMs ? " \u00b7 PDO " + periodMs + "ms" : "");
|
||||
},
|
||||
|
||||
/**
|
||||
* 连接探测 —— 一路自动到能用:
|
||||
* 服务没起 → 代理侧按需拉起(esp32_server._ecat_autostart)
|
||||
* 网卡没开 → ensureOpen() 代替用户点"连接"
|
||||
* 没进 OP → ensureStarted() 里 motor/start 自己走 PREOP→SAFEOP→OP
|
||||
* 循环没跑 → ensureStarted() 起后台 PDO 循环(喂 1000ms 看门狗)
|
||||
* 四步都自动,用户切到 EtherCAT 链路就能直接用,不必先开别的页面点两下。
|
||||
*
|
||||
* 只有 /api/status 通不算连上 —— 循环没跑就读不到任何电机状态,
|
||||
* 那样 LED 会绿着但界面全是 "--",比直接报未连接更难排查。
|
||||
*/
|
||||
async probe() {
|
||||
const st = await ensureOpen();
|
||||
await ensureStarted();
|
||||
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 " + periodMs + "ms \u00b7 单槽 " + slotPeriodMs + "ms"
|
||||
: "PDO " + periodMs + "ms");
|
||||
}
|
||||
/* 自旋会占满一个核,属于用户能感知的代价,必须在状态栏可见而不是只写在文档里。
|
||||
* 钉到 E 核时把核号一起写出来,用户才知道占的是能效核不是性能核。 */
|
||||
if (periodMs && spinOn) {
|
||||
const rng = ecoreRanges(ecoreList(ecoreMask));
|
||||
parts.push(rng ? `⚡ 自旋节拍 · 钉在 E 核 ${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;
|
||||
if (typeof snap.spin === "boolean") spinOn = snap.spin;
|
||||
if (typeof snap.ecore_mask === "number") ecoreMask = snap.ecore_mask;
|
||||
/* 有帧待取才去消费,省一次往返 */
|
||||
const rxb = snap.rx_batch;
|
||||
if (rxb && (rxb.count > 0 || rxb.pending > 0)) {
|
||||
try { await drainRx(); } catch (e) { /* 瞬时失败下拍再取 */ }
|
||||
}
|
||||
/* 长帧快照(CONFIG/版本这类 DLC24 帧在 8B 短快照里会被截断)也要分派 */
|
||||
if (snap.xrx && snap.xrx.data) {
|
||||
dispatchFrame(snap.xrx.id, snap.xrx.dlc, snap.xrx.data);
|
||||
}
|
||||
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)。
|
||||
*/
|
||||
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 api("motor/send", payload, 6000);
|
||||
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 api("motor/send", { frames: built }, 6000);
|
||||
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;
|
||||
},
|
||||
|
||||
/** 节点 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 链路打断。
|
||||
*/
|
||||
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 { localStorage.setItem(LS_ADAPTER, 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;
|
||||
},
|
||||
|
||||
/** 诊断用(控制台 motorDbg.transport)。 */
|
||||
debug() {
|
||||
return {
|
||||
periodMs, slotPeriodMs, spinOn, ecoreMask, ecores: ecoreList(ecoreMask),
|
||||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user