/** * 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"; import { openEcatAdapter, startEcatMotorLoop, repairEcatDc, resolveEcatStartBody, readStoredEcatPeriod, ECAT_DEFAULT_ADAPTER, } from "../common/ecat-bus.js"; import { desktopEcatPost, desktopBridgeLabel, getDesktopEcatApi, desktopEcatSendRaw, hasDesktopFastSend, } from "../common/desktop-bridge.js?v=20260924a"; function bytesToHexStr(bytes) { let s = ""; for (let i = 0; i < bytes.length; i++) { s += bytes[i].toString(16).padStart(2, "0").toUpperCase(); } return s; } function hexToBytes(hex) { const clean = String(hex || "").replace(/[^0-9a-fA-F]/g, ""); const out = new Uint8Array(Math.floor(clean.length / 2)); for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.substr(i * 2, 2), 16); return out; } function u16LeAt(bytes, i) { return bytes[i] | (bytes[i + 1] << 8); } function canId(node, suffix) { return ((node & 0xF) << 16) | (suffix & 0xFFFF); } const ID_GET_NODE_ID = (n) => canId(n, 0x0401); const ID_SET_NODE_ID = (n) => canId(n, 0x0400); /** 单个 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; } /** * 确保网卡已打开 —— R6:策略实现在 js/common/ecat-bus.js 单点。 * 服务刚被自动拉起时 g_master=None,任何 /api/motor/* 会回「未连接」。 */ async function ensureOpen() { try { return await openEcatAdapter(api, adapterSubstr); } catch (e) { /* open 失败(网线/网卡名)保持非瞬时,让探测横幅给出可操作原因 */ if (e && e.transient === undefined) e.transient = false; throw e; } } /** * 轻量健康:native/health 只读缓存(不抢 g_bus_lock、不 loop_stop)。 * R6:是否健康的判定与 ecat-bus.isEcatBusHealthy 同源。 */ 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, }; } /** 从 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 循环在跑。 * R6:health 复用 + DC 250µs + motor/start 的策略在 ecat-bus.startEcatMotorLoop。 */ async function ensureStarted(reqPeriodMs) { /* 日常接入:循环还在跑就别动总线(start 会先 loop_stop) */ if (!reqPeriodMs) { const startedR = await startEcatMotorLoop(api, { force: false, fromLocalStorage: true, period_ms: readStoredEcatPeriod(), }); if (startedR.reused) { 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 = startedR.body || resolveEcatStartBody({ fromLocalStorage: true, period_ms: readStoredEcatPeriod(), }); periodMs = (startedR.result && startedR.result.period_ms) || body.period_ms || periodMs; if (startedR.result && typeof startedR.result.spin === "boolean") spinOn = startedR.result.spin; started = true; return startedR.result || {}; } /* 显式周期覆盖(setPdoPeriod 等):强制 start,仍走 ecat-bus 的 DC 钳制 */ const startedR = await startEcatMotorLoop(api, { force: true, period_ms: reqPeriodMs, }); const body = startedR.body || resolveEcatStartBody({ period_ms: reqPeriodMs }); periodMs = (startedR.result && startedR.result.period_ms) || body.period_ms || periodMs; if (startedR.result && typeof startedR.result.spin === "boolean") spinOn = startedR.result.spin; started = true; return startedR.result || {}; } 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/周期被配错时发送出口秒级 —— R6 单点 repairEcatDc(250µs) */ try { await repairEcatDc(api); } 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; }, /** 健康检查: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; }, /** * R5:ECAT 版本等待 —— drain + 采样循环(不绑主 tick)。 * 旧 main.waitVersionEcat 逻辑迁到此处,行为不变。 */ async waitVersion(node, opts) { const prevSeq = opts && opts.prevSeq; const sentAt = (opts && opts.sentAt) || 0; const timeoutMs = (opts && opts.timeoutMs) || 2500; const deadline = Date.now() + timeoutMs; const sampleOnce = async () => { try { await drainRx(); } catch (_) {} const mapV = versions.get(node) || null; if (mapV && mapV.version && (prevSeq == null || mapV.seq > prevSeq || (mapV.at && mapV.at >= sentAt))) { return mapV; } try { const x = await this.peekVersionXrx(node); if (x && x.version) return x; } catch (_) {} return null; }; while (Date.now() < deadline) { const v = await sampleOnce(); if (v && v.version) return v; await new Promise((r) => setTimeout(r, 25)); } return null; }, /** * R5:节点参数 GET —— 发送走注入 sendFrame,回执走 motor/rx(takeNodeIdReport)。 */ async queryNodeIdParam(node, selector) { const sendFrame = ctx.sendFrame; if (typeof sendFrame !== "function") return { ok: false, desc: "sendFrame 未注入" }; const bytes = new Uint8Array([selector & 0xFF, (selector >> 8) & 0xFF]); const id = ID_GET_NODE_ID(node); const res = await sendFrame(id, 2, bytesToHexStr(bytes)); if (!res.ok) { return { ok: false, desc: res.desc || "发送失败", networkError: res.networkError, busy: res.busy }; } let rb = null; const r = res.results && res.results[0]; if (r && r.recv && r.recv_len >= 2) { const b = hexToBytes(r.recv); if (b.length === 2 || b.length === 4) rb = b; } if (!rb) { const deadline = Date.now() + 1500; while (Date.now() < deadline && !rb) { await new Promise((r2) => setTimeout(r2, 120)); const hex = nodeIdReports.get(node); if (hex) { nodeIdReports.delete(node); rb = hexToBytes(hex); } else { try { await drainRx(); } catch (_) {} } } } return parseParamReplyEc(rb, selector); }, /** R5:节点参数 SET —— 回执走 motor/rx(5B ACK / 旧格式)。 */ async setNodeIdParam(node, selector, value) { const sendFrame = ctx.sendFrame; if (typeof sendFrame !== "function") return { ok: false, desc: "sendFrame 未注入" }; const bytes = new Uint8Array([ selector & 0xFF, (selector >> 8) & 0xFF, value & 0xFF, (value >> 8) & 0xFF, ]); const id = ID_SET_NODE_ID(node); const res = await sendFrame(id, 4, bytesToHexStr(bytes)); if (!res.ok) { return { ok: false, desc: res.desc || "发送失败", networkError: res.networkError, busy: res.busy }; } let rb = null; const r = res.results && res.results[0]; if (r && r.recv && r.recv_len >= 5) rb = hexToBytes(r.recv); if (!rb) { const deadline = Date.now() + 1500; while (Date.now() < deadline && !rb) { await new Promise((r2) => setTimeout(r2, 120)); const hex = nodeIdReports.get(node); if (hex) { nodeIdReports.delete(node); rb = hexToBytes(hex); } else { try { await drainRx(); } catch (_) {} } } } return parseSetReplyEc(rb, selector); }, /** 版本查询等待期强制消费 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, }; }, }; } function parseParamReplyEc(rb, selector) { if (!rb) return { ok: false, desc: "未收到应答(电机离线或未响应)" }; if (rb.length >= 4) { const sel = u16LeAt(rb, 0), val = u16LeAt(rb, 2); if (sel !== selector) { return { ok: false, desc: `应答选择器不符(0x${sel.toString(16).padStart(4, "0")})` }; } return { ok: true, val }; } if (rb.length === 2) return { ok: true, oldFw: true, val: u16LeAt(rb, 0) }; return { ok: false, desc: "应答格式异常" }; } function parseSetReplyEc(rb, selector) { if (!rb) return { ok: false, desc: "未收到应答(电机离线或未响应)" }; if (rb.length >= 5) { const st = rb[0], sel = u16LeAt(rb, 1); if (sel !== selector) { return { ok: false, desc: `应答选择器不符(0x${sel.toString(16).padStart(4, "0")})` }; } return st === 1 ? { ok: true } : { ok: false, desc: "电机 NACK(Flash 写入失败或参数非法)" }; } return { ok: false, desc: "应答格式异常(电机固件不支持刷新率参数?需 v0.4.1+)" }; }