Files
DCU_DesktopController/js/motor/transport-ecat.js
T
cbb eac3717f9a feat: 添加局域网设备发现功能和链路胶囊优化
- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点
- 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果
- 实现快扫和全段扫描两种模式,支持种子地址优先探测
- 添加端点唯一写手机制,防止多处同时修改设备IP
- 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称
- 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题
- 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒
- 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
2026-10-13 00:48:34 +08:00

1058 lines
49 KiB
JavaScript
Raw Blame History

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